mirror of
https://github.com/goauthentik/authentik
synced 2026-05-11 17:36:35 +02:00
Compare commits
163 Commits
patternfly
...
version/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b0f19465b | ||
|
|
8779d7132c | ||
|
|
4b5050abd5 | ||
|
|
be4ed7c779 | ||
|
|
c385d09dd9 | ||
|
|
4b88c62f9f | ||
|
|
88a056af5d | ||
|
|
8db54884a0 | ||
|
|
32085291f0 | ||
|
|
a1c7c9a163 | ||
|
|
f626d26c56 | ||
|
|
99cff7e93e | ||
|
|
f2188d00f9 | ||
|
|
8a7129d74e | ||
|
|
1e96e0e639 | ||
|
|
13bb8e3145 | ||
|
|
7a52df2901 | ||
|
|
6d8554870f | ||
|
|
aafb7cb7dc | ||
|
|
241e674b64 | ||
|
|
b72e3b55a0 | ||
|
|
f758ed2c17 | ||
|
|
00fad79ea3 | ||
|
|
ac0df081c1 | ||
|
|
bcefa8b7a1 | ||
|
|
fb8cb21967 | ||
|
|
170b6619df | ||
|
|
a8c4c4e70d | ||
|
|
c243fe4914 | ||
|
|
6402010292 | ||
|
|
e35984096d | ||
|
|
b1272150b9 | ||
|
|
e44cf378d7 | ||
|
|
653a0ba794 | ||
|
|
dfa5378804 | ||
|
|
eec0ca4907 | ||
|
|
b95312b13b | ||
|
|
fb708188bc | ||
|
|
e084c629a7 | ||
|
|
cd04a205b4 | ||
|
|
bfdd00a622 | ||
|
|
1358eed96c | ||
|
|
467321f570 | ||
|
|
94f7a6d45d | ||
|
|
c29c0de498 | ||
|
|
f8060de2f0 | ||
|
|
9bf58f9c22 | ||
|
|
0d617e4ad1 | ||
|
|
4adc0eaf8e | ||
|
|
7de405db6d | ||
|
|
50b291d6c4 | ||
|
|
14005fe781 | ||
|
|
591153b6cd | ||
|
|
864856733e | ||
|
|
1b66803a31 | ||
|
|
d8579b02ed | ||
|
|
f98d464323 | ||
|
|
7828facc41 | ||
|
|
ffe2bde51f | ||
|
|
f6dcdd059c | ||
|
|
2629759293 | ||
|
|
1b9bd8d4af | ||
|
|
c0e5ac3127 | ||
|
|
53f4bd613f | ||
|
|
83e41efe07 | ||
|
|
ad569be1d5 | ||
|
|
064866ccc7 | ||
|
|
36593d4700 | ||
|
|
2857e4df95 | ||
|
|
28b4a927ef | ||
|
|
7a20845a03 | ||
|
|
76ca2fbf77 | ||
|
|
e4e8bc57f1 | ||
|
|
15380dee37 | ||
|
|
b4844f8800 | ||
|
|
e9ff4f79ca | ||
|
|
92fb2f0f2b | ||
|
|
f80ce9dd6c | ||
|
|
a233feec29 | ||
|
|
bc9215a2ff | ||
|
|
263a2bca6d | ||
|
|
4cc71ef161 | ||
|
|
f66c535ae0 | ||
|
|
893325a7b7 | ||
|
|
a62c73d6f1 | ||
|
|
483710a59c | ||
|
|
b8b7584e8e | ||
|
|
2fedc3d0a0 | ||
|
|
7f0b45f921 | ||
|
|
3905c281ad | ||
|
|
e6099d43f5 | ||
|
|
a91145bc7b | ||
|
|
3f38d5c7d9 | ||
|
|
c00df0573c | ||
|
|
c3a0edee00 | ||
|
|
8b81ca36ea | ||
|
|
698de68a36 | ||
|
|
db35593b24 | ||
|
|
445fa31b57 | ||
|
|
a9aa1bf2c2 | ||
|
|
d018f0381c | ||
|
|
7dd1cd5c59 | ||
|
|
c219a6804a | ||
|
|
d9310d04b0 | ||
|
|
f471ef0e2e | ||
|
|
31a010c108 | ||
|
|
96e6ab291e | ||
|
|
ebf68311c2 | ||
|
|
fd365b2a09 | ||
|
|
41104da41f | ||
|
|
7edebdec03 | ||
|
|
fb56a54eb1 | ||
|
|
31cd6eb8ce | ||
|
|
092c5eb33c | ||
|
|
3e41bba54d | ||
|
|
9f8fd6eabe | ||
|
|
35fb55da15 | ||
|
|
b1d571a5af | ||
|
|
fb589592b5 | ||
|
|
6468bb5707 | ||
|
|
70406664dc | ||
|
|
c58c194180 | ||
|
|
fad87741e7 | ||
|
|
f6679895e5 | ||
|
|
a573a72ecb | ||
|
|
b72709ebbc | ||
|
|
449742fbc0 | ||
|
|
1b02cc0dae | ||
|
|
b0945ee7e9 | ||
|
|
6682136af1 | ||
|
|
24cb5ae4c1 | ||
|
|
9e272c7121 | ||
|
|
5dc7b7cdae | ||
|
|
2e2c52e49c | ||
|
|
38f1ef0506 | ||
|
|
3517562549 | ||
|
|
cdbe40143d | ||
|
|
5816f0d17c | ||
|
|
907ea8b2e9 | ||
|
|
b38af89960 | ||
|
|
d52db187bf | ||
|
|
2093e0e63f | ||
|
|
2791d87ceb | ||
|
|
fdc3d95b59 | ||
|
|
de7a61cee0 | ||
|
|
f2805b9b8a | ||
|
|
f48a91fbf4 | ||
|
|
f056c0808d | ||
|
|
06a6d45139 | ||
|
|
0e12642f12 | ||
|
|
01406d364e | ||
|
|
b9b16dba59 | ||
|
|
1ef83f3295 | ||
|
|
343506d104 | ||
|
|
aeb4e1057e | ||
|
|
0bcd1c268c | ||
|
|
ecba1ffe94 | ||
|
|
b7d303936c | ||
|
|
c1bc2a4565 | ||
|
|
1422c3aff3 | ||
|
|
d4a77583ea | ||
|
|
78d270bf25 | ||
|
|
6d1c7f90e2 |
81
.github/ISSUE_TEMPLATE/1-bug-report.yml
vendored
81
.github/ISSUE_TEMPLATE/1-bug-report.yml
vendored
@@ -1,81 +0,0 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
labels: ["bug", "triage"]
|
||||
type: bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to fill out this bug report!
|
||||
- type: textarea
|
||||
id: describe-the-bug
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: "A clear and concise description of what the bug is."
|
||||
placeholder: "Describe the issue"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: how-to-reproduce
|
||||
attributes:
|
||||
label: How to reproduce
|
||||
description: "Steps to reproduce the behavior."
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: "A clear and concise description of what you expected to happen."
|
||||
placeholder: "The behavior that I expect to see is [...]"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: "If applicable, add screenshots to help explain your problem."
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: "Add any other context about the problem here."
|
||||
placeholder: "Also note that [...]"
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: deployment-method
|
||||
attributes:
|
||||
label: Deployment Method
|
||||
description: "What deployment method are you using for authentik? Only Docker, Kubernetes and AWS CloudFormation are supported."
|
||||
options:
|
||||
- Docker
|
||||
- Kubernetes
|
||||
- AWS CloudFormation
|
||||
- Other (please specify)
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: "What version of authentik are you using?"
|
||||
placeholder: "[e.g. 2025.10.1]"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: "Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks."
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
49
.github/ISSUE_TEMPLATE/2-docs-issue.yml
vendored
49
.github/ISSUE_TEMPLATE/2-docs-issue.yml
vendored
@@ -1,49 +0,0 @@
|
||||
name: Documentation suggestion/problem
|
||||
description: Suggest an improvement or report a problem in our docs
|
||||
labels: ["area: docs", "triage"]
|
||||
type: task
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to fill out this documentation issue!
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
|
||||
**Consider opening a PR!**
|
||||
If the issue is one that you can fix, or even make a good pass at, we'd appreciate a PR.
|
||||
|
||||
For more information about making a contribution to the docs, and using our Style Guide and our templates, refer to ["Writing documentation"](https://docs.goauthentik.io/docs/developer-docs/docs/writing-documentation).
|
||||
- type: textarea
|
||||
id: issue
|
||||
attributes:
|
||||
label: Do you see an area that can be clarified or expanded, a technical inaccuracy, or a broken link?
|
||||
description: "A clear and concise description of what the problem is, or where the document can be improved."
|
||||
placeholder: "I believe we need more details about [...]"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: link
|
||||
attributes:
|
||||
label: Link
|
||||
description: "Provide the URL or link to the exact page in the documentation to which you are referring."
|
||||
placeholder: "If there are multiple pages, list them all"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Solution
|
||||
description: "A clear and concise description of what you suggest as a solution"
|
||||
placeholder: "This issue could be resolved by [...]"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: "Add any other context or screenshots about the documentation issue here."
|
||||
placeholder: "Also note that [...]"
|
||||
validations:
|
||||
required: false
|
||||
41
.github/ISSUE_TEMPLATE/3-feature-request.yml
vendored
41
.github/ISSUE_TEMPLATE/3-feature-request.yml
vendored
@@ -1,41 +0,0 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea for a feature
|
||||
labels: ["enhancement", "triage"]
|
||||
type: feature
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to fill out this feature request!
|
||||
- type: textarea
|
||||
id: related-to-problem
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
description: "A clear and concise description of what the problem is."
|
||||
placeholder: "I'm always frustrated when [...]"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: feature
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
placeholder: "I'd like authentik to have [...]"
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Describe alternatives that you've considered
|
||||
description: "A clear and concise description of any alternative solutions or features you've considered."
|
||||
placeholder: "I've tried this but [...]"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: "Add any other context or screenshots about the feature request here."
|
||||
placeholder: "Also note that [...]"
|
||||
validations:
|
||||
required: false
|
||||
39
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
39
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ""
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Logs**
|
||||
Output of docker-compose logs or kubectl logs respectively
|
||||
|
||||
**Version and Deployment (please complete the following information):**
|
||||
|
||||
<!--
|
||||
Notice: authentik supports installation via Docker, Kubernetes, and AWS CloudFormation only. Support is not available for other methods. For detailed installation and configuration instructions, please refer to the official documentation at https://docs.goauthentik.io/docs/install-config/.
|
||||
-->
|
||||
|
||||
- authentik version: [e.g. 2025.2.0]
|
||||
- Deployment: [e.g. docker-compose, helm]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Question
|
||||
url: https://github.com/goauthentik/authentik/discussions
|
||||
about: Please ask questions via GitHub Discussions rather than creating issues.
|
||||
- name: authentik Discord
|
||||
url: https://discord.com/invite/jg33eMhnj6
|
||||
about: For community support, visit our Discord server.
|
||||
22
.github/ISSUE_TEMPLATE/docs_issue.md
vendored
Normal file
22
.github/ISSUE_TEMPLATE/docs_issue.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: Documentation issue
|
||||
about: Suggest an improvement or report a problem
|
||||
title: ""
|
||||
labels: documentation
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Do you see an area that can be clarified or expanded, a technical inaccuracy, or a broken link? Please describe.**
|
||||
A clear and concise description of what the problem is, or where the document can be improved. Ex. I believe we need more details about [...]
|
||||
|
||||
**Provide the URL or link to the exact page in the documentation to which you are referring.**
|
||||
If there are multiple pages, list them all, and be sure to state the header or section where the content is.
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the documentation issue here.
|
||||
|
||||
**Consider opening a PR!**
|
||||
If the issue is one that you can fix, or even make a good pass at, we'd appreciate a PR. For more information about making a contribution to the docs, and using our Style Guide and our templates, refer to ["Writing documentation"](https://docs.goauthentik.io/docs/developer-docs/docs/writing-documentation).
|
||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ""
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
17
.github/ISSUE_TEMPLATE/hackathon_idea.md
vendored
Normal file
17
.github/ISSUE_TEMPLATE/hackathon_idea.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: Hackathon Idea
|
||||
about: Propose an idea for the hackathon
|
||||
title: ""
|
||||
labels: hackathon
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the idea**
|
||||
|
||||
A clear concise description of the idea you want to implement
|
||||
|
||||
You're also free to work on existing GitHub issues, whether they be feature requests or bugs, just link the existing GitHub issue here.
|
||||
|
||||
<!-- Don't modify below here -->
|
||||
|
||||
If you want to help working on this idea or want to contribute in any other way, react to this issue with a :rocket:
|
||||
7
.github/ISSUE_TEMPLATE/issue_template.md
vendored
7
.github/ISSUE_TEMPLATE/issue_template.md
vendored
@@ -1,7 +0,0 @@
|
||||
---
|
||||
name: Blank issue
|
||||
about: This issue type is only for internal use
|
||||
title:
|
||||
labels:
|
||||
assignees:
|
||||
---
|
||||
32
.github/ISSUE_TEMPLATE/question.md
vendored
Normal file
32
.github/ISSUE_TEMPLATE/question.md
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: Question
|
||||
about: Ask a question about a feature or specific configuration
|
||||
title: ""
|
||||
labels: question
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe your question/**
|
||||
A clear and concise description of what you're trying to do.
|
||||
|
||||
**Relevant info**
|
||||
i.e. Version of other software you're using, specifics of your setup
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Logs**
|
||||
Output of docker-compose logs or kubectl logs respectively
|
||||
|
||||
**Version and Deployment (please complete the following information):**
|
||||
|
||||
<!--
|
||||
Notice: authentik supports installation via Docker, Kubernetes, and AWS CloudFormation only. Support is not available for other methods. For detailed installation and configuration instructions, please refer to the official documentation at https://docs.goauthentik.io/docs/install-config/.
|
||||
-->
|
||||
|
||||
|
||||
- authentik version: [e.g. 2025.2.0]
|
||||
- Deployment: [e.g. docker-compose, helm]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
@@ -21,7 +21,7 @@ runs:
|
||||
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
|
||||
- name: Install uv
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v5
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v5
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Setup python
|
||||
|
||||
@@ -42,8 +42,8 @@ jobs:
|
||||
# Needed for checkout
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
@@ -67,21 +67,20 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: make empty clients
|
||||
if: ${{ inputs.release }}
|
||||
run: |
|
||||
mkdir -p ./gen-ts-api
|
||||
mkdir -p ./gen-go-api
|
||||
- name: Setup node
|
||||
if: ${{ !inputs.release }}
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: generate ts client
|
||||
if: ${{ !inputs.release }}
|
||||
run: make gen-client-ts
|
||||
- name: Setup go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Generate API Clients
|
||||
run: |
|
||||
make gen-client-ts
|
||||
make gen-client-go
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
id: push
|
||||
|
||||
4
.github/workflows/_reusable-docker-build.yml
vendored
4
.github/workflows/_reusable-docker-build.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
tags: ${{ steps.ev.outputs.imageTagsJSON }}
|
||||
shouldPush: ${{ steps.ev.outputs.shouldPush }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
matrix:
|
||||
tag: ${{ fromJson(needs.get-tags.outputs.tags) }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
|
||||
2
.github/workflows/api-ts-publish.yml
vendored
2
.github/workflows/api-ts-publish.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
|
||||
10
.github/workflows/ci-api-docs.yml
vendored
10
.github/workflows/ci-api-docs.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
command:
|
||||
- prettier-check
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Install Dependencies
|
||||
working-directory: website/
|
||||
run: npm ci
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
env:
|
||||
NODE_ENV: production
|
||||
run: npm run build -w api
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: api-docs
|
||||
path: website/api/build
|
||||
@@ -66,8 +66,8 @@ jobs:
|
||||
- lint
|
||||
- build
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
||||
with:
|
||||
name: api-docs
|
||||
path: website/api/build
|
||||
|
||||
4
.github/workflows/ci-aws-cfn.yml
vendored
4
.github/workflows/ci-aws-cfn.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
check-changes-applied:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
@@ -42,6 +42,6 @@ jobs:
|
||||
- check-changes-applied
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
|
||||
- uses: re-actors/alls-green@release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
|
||||
2
.github/workflows/ci-docs-source.yml
vendored
2
.github/workflows/ci-docs-source.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: generate docs
|
||||
|
||||
12
.github/workflows/ci-docs.yml
vendored
12
.github/workflows/ci-docs.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
command:
|
||||
- prettier-check
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Install dependencies
|
||||
working-directory: website/
|
||||
run: npm ci
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
@@ -69,11 +69,11 @@ jobs:
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
@@ -117,6 +117,6 @@ jobs:
|
||||
- build-container
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
|
||||
- uses: re-actors/alls-green@release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
|
||||
2
.github/workflows/ci-main-daily.yml
vendored
2
.github/workflows/ci-main-daily.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
- version-2025-4
|
||||
- version-2025-2
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- run: |
|
||||
current="$(pwd)"
|
||||
dir="/tmp/authentik/${{ matrix.version }}"
|
||||
|
||||
21
.github/workflows/ci-main.yml
vendored
21
.github/workflows/ci-main.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
- mypy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: run job
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
test-migrations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: run migrations
|
||||
@@ -71,12 +71,11 @@ jobs:
|
||||
- 18-alpine
|
||||
run_id: [1, 2, 3, 4, 5]
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: checkout stable
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
# Copy current, latest config to local
|
||||
cp authentik/lib/default.yml local.env.yml
|
||||
cp -R .github ..
|
||||
@@ -84,7 +83,7 @@ jobs:
|
||||
# Previous stable tag
|
||||
prev_stable=$(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
|
||||
# Current version family based on
|
||||
current_version_family=$(cat internal/constants/VERSION | grep -vE -- 'rc[0-9]+$' || true)
|
||||
current_version_family=$(python -c "from authentik import VERSION; print(VERSION)" | grep -vE -- 'rc[0-9]+$')
|
||||
if [[ -n $current_version_family ]]; then
|
||||
prev_stable=$current_version_family
|
||||
fi
|
||||
@@ -138,7 +137,7 @@ jobs:
|
||||
- 18-alpine
|
||||
run_id: [1, 2, 3, 4, 5]
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -158,11 +157,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Create k8s Kind Cluster
|
||||
uses: helm/kind-action@92086f6be054225fa813e0a4b13787fc9088faab # v1.13.0
|
||||
uses: helm/kind-action@a1b0e391336a6ee6713a0583f8c6240d70863de3 # v1.12.0
|
||||
- name: run integration
|
||||
run: |
|
||||
uv run coverage run manage.py test tests/integration
|
||||
@@ -196,7 +195,7 @@ jobs:
|
||||
- name: flows
|
||||
glob: tests/e2e/test_flows*
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Setup e2e env (chrome, etc)
|
||||
@@ -234,7 +233,7 @@ jobs:
|
||||
- test-e2e
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
|
||||
- uses: re-actors/alls-green@release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
build:
|
||||
@@ -262,7 +261,7 @@ jobs:
|
||||
pull-requests: write
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: prepare variables
|
||||
|
||||
14
.github/workflows/ci-outpost.yml
vendored
14
.github/workflows/ci-outpost.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
lint-golint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Generate API
|
||||
run: make gen-client-go
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@0a35821d5c230e903fcfe077583637dea1b27b47 # v8
|
||||
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout 5000s --verbose
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
test-unittest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
- test-unittest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
|
||||
- uses: re-actors/alls-green@release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
build-container:
|
||||
@@ -86,11 +86,11 @@ jobs:
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
goos: [linux]
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
|
||||
8
.github/workflows/ci-web.yml
vendored
8
.github/workflows/ci-web.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
- command: lit-analyse
|
||||
project: web
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: ${{ matrix.project }}/package.json
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
- lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
|
||||
- uses: re-actors/alls-green@release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
test:
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
- ci-web-mark
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
|
||||
4
.github/workflows/gen-image-compress.yml
vendored
4
.github/workflows/gen-image-compress.yml
vendored
@@ -33,12 +33,12 @@ jobs:
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- name: Compress images
|
||||
id: compress
|
||||
uses: calibreapp/image-actions@420075c115b26f8785e293c5bd5bef0911c506e5 # main
|
||||
uses: calibreapp/image-actions@05b1cf44e88c3b041b841452482df9497f046ef7 # main
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
compressOnly: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- name: Setup authentik env
|
||||
|
||||
2
.github/workflows/gh-cherry-pick.yml
vendored
2
.github/workflows/gh-cherry-pick.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
env:
|
||||
GH_APP_ID: ${{ secrets.GH_APP_ID }}
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
if: ${{ steps.app-token.outcome != 'skipped' }}
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
2
.github/workflows/gh-gha-cache-cleanup.yml
vendored
2
.github/workflows/gh-gha-cache-cleanup.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
|
||||
2
.github/workflows/packages-npm-publish.yml
vendored
2
.github/workflows/packages-npm-publish.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
- packages/tsconfig
|
||||
- packages/esbuild-plugin-live-reload
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
|
||||
2
.github/workflows/qa-codeql.yml
vendored
2
.github/workflows/qa-codeql.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
language: ["go", "javascript", "python"]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Initialize CodeQL
|
||||
|
||||
2
.github/workflows/qa-semgrep.yml
vendored
2
.github/workflows/qa-semgrep.yml
vendored
@@ -26,5 +26,5 @@ jobs:
|
||||
image: semgrep/semgrep
|
||||
if: (github.actor != 'dependabot[bot]')
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- run: semgrep ci
|
||||
|
||||
4
.github/workflows/release-branch-off.yml
vendored
4
.github/workflows/release-branch-off.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
ref: main
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
2
.github/workflows/release-next-branch.yml
vendored
2
.github/workflows/release-next-branch.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: internal-production
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
ref: main
|
||||
- run: |
|
||||
|
||||
31
.github/workflows/release-publish.yml
vendored
31
.github/workflows/release-publish.yml
vendored
@@ -31,9 +31,9 @@ jobs:
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
@@ -83,12 +83,17 @@ jobs:
|
||||
- radius
|
||||
- rac
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
@@ -98,10 +103,10 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/${{ matrix.type }},authentik/${{ matrix.type }}
|
||||
- name: make empty clients
|
||||
- name: Generate API Clients
|
||||
run: |
|
||||
mkdir -p ./gen-ts-api
|
||||
mkdir -p ./gen-go-api
|
||||
make gen-client-ts
|
||||
make gen-client-go
|
||||
- name: Docker Login Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
@@ -146,7 +151,7 @@ jobs:
|
||||
goos: [linux, darwin]
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
@@ -186,7 +191,7 @@ jobs:
|
||||
AWS_REGION: eu-central-1
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- uses: aws-actions/configure-aws-credentials@00943011d9042930efac3dcd3a170e4273319bc8 # v5
|
||||
with:
|
||||
role-to-assume: "arn:aws:iam::016170277896:role/github_goauthentik_authentik"
|
||||
@@ -202,7 +207,7 @@ jobs:
|
||||
- build-outpost-binary
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Run test suite in final docker images
|
||||
run: |
|
||||
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env
|
||||
@@ -218,7 +223,7 @@ jobs:
|
||||
- build-outpost-binary
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
@@ -232,7 +237,7 @@ jobs:
|
||||
container=$(docker container create ${{ steps.ev.outputs.imageMainName }})
|
||||
docker cp ${container}:web/ .
|
||||
- name: Create a Sentry.io release
|
||||
uses: getsentry/action-release@128c5058bbbe93c8e02147fe0a9c713f166259a6 # v3
|
||||
uses: getsentry/action-release@4f502acc1df792390abe36f2dcb03612ef144818 # v3
|
||||
continue-on-error: true
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
18
.github/workflows/release-tag.yml
vendored
18
.github/workflows/release-tag.yml
vendored
@@ -35,10 +35,8 @@ jobs:
|
||||
echo "major_version=${{ inputs.version }}" | grep -oE "^major_version=[0-9]{4}\.[0-9]{1,2}" >> "$GITHUB_OUTPUT"
|
||||
- id: changelog-url
|
||||
run: |
|
||||
if [ "${{ inputs.release_reason }}" = "feature" ]; then
|
||||
if [ "${{ inputs.release_reason }}" = "feature" ] || [ "${{ inputs.release_reason }}" = "prerelease" ]; then
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ steps.check.outputs.major_version }}"
|
||||
elif [ "${{ inputs.release_reason }}" = "prerelease" ]; then
|
||||
changelog_url="https://next.goauthentik.io/docs/releases/${{ steps.check.outputs.major_version }}"
|
||||
else
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ steps.check.outputs.major_version }}#fixed-in-$(echo -n ${{ inputs.version }} | sed 's/\.//g')"
|
||||
fi
|
||||
@@ -49,8 +47,14 @@ jobs:
|
||||
test:
|
||||
name: Pre-release test
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- check-inputs
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- run: make test-docker
|
||||
bump-authentik:
|
||||
name: Bump authentik version
|
||||
@@ -70,7 +74,7 @@ jobs:
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
@@ -118,7 +122,7 @@ jobs:
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/helm"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
@@ -160,7 +164,7 @@ jobs:
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/version"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
|
||||
@@ -25,11 +25,11 @@ jobs:
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
2
.github/workflows/translation-rename.yml
vendored
2
.github/workflows/translation-rename.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.pull_request.user.login == 'transifex-integration[bot]'}}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2
|
||||
with:
|
||||
|
||||
@@ -26,7 +26,7 @@ RUN npm run build && \
|
||||
npm run build:sfe
|
||||
|
||||
# Stage 2: Build go proxy
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.4-trixie@sha256:27e1c927a07ed2c7295d39941d6d881424739dbde9ae3055d0d3013699ed35e8 AS go-builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.3-trixie@sha256:7534a6264850325fcce93e47b87a0e3fddd96b308440245e6ab1325fa8a44c91 AS go-builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -44,6 +44,7 @@ RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/v
|
||||
|
||||
RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
|
||||
--mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \
|
||||
--mount=type=bind,target=/go/src/goauthentik.io/gen-go-api,src=./gen-go-api \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
@@ -57,6 +58,7 @@ COPY ./go.mod /go/src/goauthentik.io/go.mod
|
||||
COPY ./go.sum /go/src/goauthentik.io/go.sum
|
||||
|
||||
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||
--mount=type=bind,target=/go/src/goauthentik.io/gen-go-api,src=./gen-go-api \
|
||||
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
||||
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
||||
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
|
||||
@@ -76,7 +78,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||
|
||||
# Stage 4: Download uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.10@sha256:29bd45092ea8902c0bbb7f0a338f0494a382b1f4b18355df5be270ade679ff1d AS uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.6@sha256:4b96ee9429583983fd172c33a02ecac5242d63fb46bc27804748e38c1cc9ad0d AS uv
|
||||
# Stage 5: Base python image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips@sha256:700fc8c1e290bd14e5eaca50b1d8e8c748c820010559cbfb4c4f8dfbe2c4c9ff AS python-base
|
||||
|
||||
|
||||
10
Makefile
10
Makefile
@@ -189,23 +189,15 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
|
||||
|
||||
gen-client-py: gen-clean-py ## Build and install the authentik API for Python
|
||||
mkdir -p ${PWD}/${GEN_API_PY}
|
||||
ifeq ($(wildcard ${PWD}/${GEN_API_PY}/.*),)
|
||||
git clone --depth 1 https://github.com/goauthentik/client-python.git ${PWD}/${GEN_API_PY}
|
||||
else
|
||||
cd ${PWD}/${GEN_API_PY} && git pull
|
||||
endif
|
||||
cp ${PWD}/schema.yml ${PWD}/${GEN_API_PY}
|
||||
make -C ${PWD}/${GEN_API_PY} build version=${NPM_VERSION}
|
||||
|
||||
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
|
||||
mkdir -p ${PWD}/${GEN_API_GO}
|
||||
ifeq ($(wildcard ${PWD}/${GEN_API_GO}/.*),)
|
||||
git clone --depth 1 https://github.com/goauthentik/client-go.git ${PWD}/${GEN_API_GO}
|
||||
else
|
||||
cd ${PWD}/${GEN_API_GO} && git pull
|
||||
endif
|
||||
cp ${PWD}/schema.yml ${PWD}/${GEN_API_GO}
|
||||
make -C ${PWD}/${GEN_API_GO} build
|
||||
make -C ${PWD}/${GEN_API_GO} build version=${NPM_VERSION}
|
||||
go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO}
|
||||
|
||||
gen-dev-config: ## Generate a local development config file
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
[](https://github.com/goauthentik/authentik/actions/workflows/ci-web.yml)
|
||||
[](https://codecov.io/gh/goauthentik/authentik)
|
||||

|
||||
[](https://explore.transifex.com/authentik/authentik/)
|
||||
[](https://www.transifex.com/authentik/authentik/)
|
||||
|
||||
## What is authentik?
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
|
||||
|
||||
(.x being the latest patch release for each version)
|
||||
|
||||
| Version | Supported |
|
||||
| ---------- | ---------- |
|
||||
| 2025.8.x | ✅ |
|
||||
| 2025.10.x | ✅ |
|
||||
| Version | Supported |
|
||||
| --------- | --------- |
|
||||
| 2025.6.x | ✅ |
|
||||
| 2025.8.x | ✅ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from functools import lru_cache
|
||||
from os import environ
|
||||
|
||||
VERSION = "2025.12.0-rc1"
|
||||
VERSION = "2025.10.4"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@@ -313,7 +313,6 @@ class Importer:
|
||||
|
||||
serializer_kwargs = {}
|
||||
model_instance = existing_models.first()
|
||||
override_serializer_instance = False
|
||||
if (
|
||||
not isinstance(model(), BaseMetaModel)
|
||||
and model_instance
|
||||
@@ -342,7 +341,11 @@ class Importer:
|
||||
model=model,
|
||||
**cleanse_dict(updated_identifiers),
|
||||
)
|
||||
override_serializer_instance = True
|
||||
model_instance = model()
|
||||
# pk needs to be set on the model instance otherwise a new one will be generated
|
||||
if "pk" in updated_identifiers:
|
||||
model_instance.pk = updated_identifiers["pk"]
|
||||
serializer_kwargs["instance"] = model_instance
|
||||
try:
|
||||
full_data = self.__update_pks_for_attrs(entry.get_attrs(self._import))
|
||||
except ValueError as exc:
|
||||
@@ -365,12 +368,6 @@ class Importer:
|
||||
entry=entry,
|
||||
serializer=serializer,
|
||||
) from exc
|
||||
if override_serializer_instance:
|
||||
model_instance = model()
|
||||
# pk needs to be set on the model instance otherwise a new one will be generated
|
||||
if "pk" in updated_identifiers:
|
||||
model_instance.pk = updated_identifiers["pk"]
|
||||
serializer.instance = model_instance
|
||||
return serializer
|
||||
|
||||
def _apply_permissions(self, instance: Model, entry: BlueprintEntry):
|
||||
@@ -449,7 +446,7 @@ class Importer:
|
||||
self._apply_permissions(instance, entry)
|
||||
elif state == BlueprintEntryDesiredState.ABSENT:
|
||||
instance: Model | None = serializer.instance
|
||||
if instance and instance.pk:
|
||||
if instance.pk:
|
||||
instance.delete()
|
||||
self.logger.debug("Deleted model", mode=instance)
|
||||
continue
|
||||
|
||||
@@ -4,7 +4,8 @@ from collections.abc import Iterator
|
||||
from copy import copy
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models import Case, QuerySet
|
||||
from django.db.models.expressions import When
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
@@ -23,6 +24,7 @@ from authentik.api.pagination import Pagination
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.users import UserSerializer
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.events.logs import LogEventSerializer, capture_logs
|
||||
@@ -63,9 +65,21 @@ class ApplicationSerializer(ModelSerializer):
|
||||
def get_launch_url(self, app: Application) -> str | None:
|
||||
"""Allow formatting of launch URL"""
|
||||
user = None
|
||||
user_data = None
|
||||
|
||||
if "request" in self.context:
|
||||
user = self.context["request"].user
|
||||
return app.get_launch_url(user)
|
||||
|
||||
# Cache serialized user data to avoid N+1 when formatting launch URLs
|
||||
# for multiple applications. UserSerializer accesses user.ak_groups which
|
||||
# would otherwise trigger a query for each application.
|
||||
if user is not None:
|
||||
if "_cached_user_data" not in self.context:
|
||||
# Prefetch groups to avoid N+1
|
||||
self.context["_cached_user_data"] = UserSerializer(instance=user).data
|
||||
user_data = self.context["_cached_user_data"]
|
||||
|
||||
return app.get_launch_url(user, user_data=user_data)
|
||||
|
||||
def validate_slug(self, slug: str) -> str:
|
||||
if slug in Application.reserved_slugs:
|
||||
@@ -158,8 +172,23 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
applications.append(application)
|
||||
return applications
|
||||
|
||||
def _expand_applications(self, applications: list[Application]) -> QuerySet[Application]:
|
||||
"""
|
||||
Re-fetch with proper prefetching for serialization
|
||||
Cached applications don't have prefetched relationships, causing N+1 queries
|
||||
during serialization when get_provider() is called
|
||||
"""
|
||||
if not applications:
|
||||
return self.get_queryset().none()
|
||||
pks = [app.pk for app in applications]
|
||||
return (
|
||||
self.get_queryset()
|
||||
.filter(pk__in=pks)
|
||||
.order_by(Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(pks)]))
|
||||
)
|
||||
|
||||
def _filter_applications_with_launch_url(
|
||||
self, paginated_apps: Iterator[Application]
|
||||
self, paginated_apps: QuerySet[Application]
|
||||
) -> list[Application]:
|
||||
applications = []
|
||||
for app in paginated_apps:
|
||||
@@ -262,6 +291,8 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
except ValueError as exc:
|
||||
raise ValidationError from exc
|
||||
allowed_applications = self._get_allowed_applications(paginated_apps, user=for_user)
|
||||
allowed_applications = self._expand_applications(allowed_applications)
|
||||
|
||||
serializer = self.get_serializer(allowed_applications, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@@ -280,6 +311,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
allowed_applications,
|
||||
timeout=86400,
|
||||
)
|
||||
allowed_applications = self._expand_applications(allowed_applications)
|
||||
|
||||
if only_with_launch_url == "true":
|
||||
allowed_applications = self._filter_applications_with_launch_url(allowed_applications)
|
||||
|
||||
@@ -18,10 +18,14 @@ from authentik.core.models import Provider
|
||||
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""Provider Serializer"""
|
||||
|
||||
assigned_application_slug = ReadOnlyField(source="application.slug")
|
||||
assigned_application_name = ReadOnlyField(source="application.name")
|
||||
assigned_backchannel_application_slug = ReadOnlyField(source="backchannel_application.slug")
|
||||
assigned_backchannel_application_name = ReadOnlyField(source="backchannel_application.name")
|
||||
assigned_application_slug = ReadOnlyField(source="application.slug", allow_null=True)
|
||||
assigned_application_name = ReadOnlyField(source="application.name", allow_null=True)
|
||||
assigned_backchannel_application_slug = ReadOnlyField(
|
||||
source="backchannel_application.slug", allow_null=True
|
||||
)
|
||||
assigned_backchannel_application_name = ReadOnlyField(
|
||||
source="backchannel_application.name", allow_null=True
|
||||
)
|
||||
|
||||
component = SerializerMethodField()
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from uuid import uuid4
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from django.utils.translation import override
|
||||
@@ -47,7 +47,7 @@ async def aget_user(request):
|
||||
|
||||
|
||||
class AuthenticationMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request):
|
||||
def process_request(self, request: HttpRequest) -> HttpResponseBadRequest | None:
|
||||
if not hasattr(request, "session"):
|
||||
raise ImproperlyConfigured(
|
||||
"The Django authentication middleware requires session "
|
||||
@@ -62,7 +62,8 @@ class AuthenticationMiddleware(MiddlewareMixin):
|
||||
user = request.user
|
||||
if user and user.is_authenticated and not user.is_active:
|
||||
logout(request)
|
||||
raise AssertionError()
|
||||
return HttpResponseBadRequest()
|
||||
return None
|
||||
|
||||
|
||||
class ImpersonateMiddleware:
|
||||
|
||||
@@ -44,19 +44,18 @@ from authentik.tenants.models import DEFAULT_TOKEN_DURATION, DEFAULT_TOKEN_LENGT
|
||||
from authentik.tenants.utils import get_current_tenant, get_unique_identifier
|
||||
|
||||
LOGGER = get_logger()
|
||||
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
|
||||
USER_ATTRIBUTE_GENERATED = "goauthentik.io/user/generated"
|
||||
USER_ATTRIBUTE_EXPIRES = "goauthentik.io/user/expires"
|
||||
USER_ATTRIBUTE_DELETE_ON_LOGOUT = "goauthentik.io/user/delete-on-logout"
|
||||
USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources"
|
||||
USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires" # nosec
|
||||
USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME = "goauthentik.io/user/token-maximum-lifetime" # nosec
|
||||
USER_ATTRIBUTE_CHANGE_USERNAME = "goauthentik.io/user/can-change-username"
|
||||
USER_ATTRIBUTE_CHANGE_NAME = "goauthentik.io/user/can-change-name"
|
||||
USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email"
|
||||
USER_PATH_SYSTEM_PREFIX = "goauthentik.io"
|
||||
_USER_ATTR_PREFIX = f"{USER_PATH_SYSTEM_PREFIX}/user"
|
||||
USER_ATTRIBUTE_DEBUG = f"{_USER_ATTR_PREFIX}/debug"
|
||||
USER_ATTRIBUTE_GENERATED = f"{_USER_ATTR_PREFIX}/generated"
|
||||
USER_ATTRIBUTE_EXPIRES = f"{_USER_ATTR_PREFIX}/expires"
|
||||
USER_ATTRIBUTE_DELETE_ON_LOGOUT = f"{_USER_ATTR_PREFIX}/delete-on-logout"
|
||||
USER_ATTRIBUTE_SOURCES = f"{_USER_ATTR_PREFIX}/sources"
|
||||
USER_ATTRIBUTE_TOKEN_EXPIRING = f"{_USER_ATTR_PREFIX}/token-expires" # nosec
|
||||
USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME = f"{_USER_ATTR_PREFIX}/token-maximum-lifetime" # nosec
|
||||
USER_ATTRIBUTE_CHANGE_USERNAME = f"{_USER_ATTR_PREFIX}/can-change-username"
|
||||
USER_ATTRIBUTE_CHANGE_NAME = f"{_USER_ATTR_PREFIX}/can-change-name"
|
||||
USER_ATTRIBUTE_CHANGE_EMAIL = f"{_USER_ATTR_PREFIX}/can-change-email"
|
||||
USER_PATH_SERVICE_ACCOUNT = f"{USER_PATH_SYSTEM_PREFIX}/service-accounts"
|
||||
USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts"
|
||||
|
||||
options.DEFAULT_NAMES = options.DEFAULT_NAMES + (
|
||||
# used_by API that allows models to specify if they shadow an object
|
||||
@@ -525,6 +524,10 @@ class ApplicationQuerySet(QuerySet):
|
||||
qs = self.select_related("provider")
|
||||
for subclass in Provider.objects.get_queryset()._get_subclasses_recurse(Provider):
|
||||
qs = qs.select_related(f"provider__{subclass}")
|
||||
# Also prefetch/select through each subclass path to ensure casted instances have access
|
||||
qs = qs.prefetch_related(f"provider__{subclass}__property_mappings")
|
||||
qs = qs.select_related(f"provider__{subclass}__application")
|
||||
qs = qs.select_related(f"provider__{subclass}__backchannel_application")
|
||||
return qs
|
||||
|
||||
|
||||
@@ -584,8 +587,15 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
return CONFIG.get("web.path", "/")[:-1] + self.meta_icon.name
|
||||
return self.meta_icon.url
|
||||
|
||||
def get_launch_url(self, user: Optional["User"] = None) -> str | None:
|
||||
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
|
||||
def get_launch_url(
|
||||
self, user: Optional["User"] = None, user_data: dict | None = None
|
||||
) -> str | None:
|
||||
"""Get launch URL if set, otherwise attempt to get launch URL based on provider.
|
||||
|
||||
Args:
|
||||
user: User instance for formatting the URL
|
||||
user_data: Pre-serialized user data to avoid re-serialization (performance optimization)
|
||||
"""
|
||||
from authentik.core.api.users import UserSerializer
|
||||
|
||||
url = None
|
||||
@@ -595,7 +605,10 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
url = provider.launch_url
|
||||
if user and url:
|
||||
try:
|
||||
return url % UserSerializer(instance=user).data
|
||||
# Use pre-serialized data if available, otherwise serialize now
|
||||
if user_data is None:
|
||||
user_data = UserSerializer(instance=user).data
|
||||
return url % user_data
|
||||
except Exception as exc: # noqa
|
||||
LOGGER.warning("Failed to format launch url", exc=exc)
|
||||
return url
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""authentik core signals"""
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.contrib.auth.signals import user_logged_in
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Model
|
||||
@@ -17,6 +19,8 @@ from authentik.core.models import (
|
||||
User,
|
||||
default_token_duration,
|
||||
)
|
||||
from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication
|
||||
from authentik.root.ws.consumer import build_device_group
|
||||
|
||||
# Arguments: user: User, password: str
|
||||
password_changed = Signal()
|
||||
@@ -47,6 +51,16 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
|
||||
if session:
|
||||
session.save()
|
||||
|
||||
if not RefreshOtherFlowsAfterAuthentication().get():
|
||||
return
|
||||
layer = get_channel_layer()
|
||||
device_cookie = request.COOKIES.get("authentik_device")
|
||||
if device_cookie:
|
||||
async_to_sync(layer.group_send)(
|
||||
build_device_group(device_cookie),
|
||||
{"type": "event.session.authenticated"},
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=AuthenticatedSession)
|
||||
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
|
||||
|
||||
@@ -35,8 +35,13 @@ def clean_expired_models():
|
||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||
clear_expired_cache()
|
||||
Message.delete_expired()
|
||||
GroupChannel.delete_expired()
|
||||
for cls in [Message, GroupChannel]:
|
||||
objects = cls.objects.all().filter(expires__lt=now())
|
||||
amount = objects.count()
|
||||
for obj in chunked_queryset(objects):
|
||||
obj.delete()
|
||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||
|
||||
|
||||
@actor(description=_("Remove temporary users created by SAML Sources."))
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
{% load i18n %}
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
|
||||
<script data-id="authentik-config">
|
||||
"use strict";
|
||||
|
||||
<script>
|
||||
window.authentik = {
|
||||
locale: "{{ LANGUAGE_CODE }}",
|
||||
config: JSON.parse('{{ config_json|escapejs }}' || "{}"),
|
||||
brand: JSON.parse('{{ brand_json|escapejs }}' || "{}"),
|
||||
config: JSON.parse('{{ config_json|escapejs }}'),
|
||||
brand: JSON.parse('{{ brand_json|escapejs }}'),
|
||||
versionFamily: "{{ version_family }}",
|
||||
versionSubdomain: "{{ version_subdomain }}",
|
||||
build: "{{ build }}",
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div class="ak-c-placeholder" id="ak-placeholder" slot="placeholder">
|
||||
<span
|
||||
class="pf-c-spinner"
|
||||
role="progressbar"
|
||||
aria-valuetext="Loading..."
|
||||
>
|
||||
<span class="pf-c-spinner__clipper"></span>
|
||||
<span class="pf-c-spinner__lead-ball"></span>
|
||||
<span class="pf-c-spinner__tail-ball"></span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -3,10 +3,8 @@
|
||||
{% load authentik_core %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
data-theme="{% if ui_theme == "dark" %}dark{% else %}light{% endif %}"
|
||||
data-theme-choice="{% if ui_theme == "dark" %}dark{% elif ui_theme == "light" %}light{% else %}auto{% endif %}"
|
||||
>
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
@@ -20,8 +18,11 @@
|
||||
|
||||
{% include "base/theme.html" %}
|
||||
|
||||
<style data-id="brand-css">{{ brand_css }}</style>
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
||||
|
||||
<style>{{ brand_css }}</style>
|
||||
<script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script>
|
||||
<script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script>
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
{% for key, value in html_meta.items %}
|
||||
|
||||
@@ -1,45 +1,10 @@
|
||||
{% load static %}
|
||||
{% load authentik_core %}
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="{% versioned_script 'dist/styles/interface-%v.css' %}" />
|
||||
|
||||
{% if ui_theme == "dark" %}
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#18191a">
|
||||
|
||||
{% elif ui_theme == "light" %}
|
||||
{% elif ui_theme == "light" %}
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
{% else %}
|
||||
<script data-id="theme-script">
|
||||
"use strict";
|
||||
|
||||
(function () {
|
||||
try {
|
||||
const initialThemeChoice =
|
||||
new URLSearchParams(window.location.search).get("theme") ||
|
||||
window.localStorage?.getItem("theme");
|
||||
|
||||
const themeChoice =
|
||||
initialThemeChoice || document.documentElement.dataset.themeChoice || "auto";
|
||||
|
||||
document.documentElement.dataset.themeChoice = themeChoice;
|
||||
|
||||
if (themeChoice === "auto") {
|
||||
document.documentElement.dataset.theme = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
).matches
|
||||
? "dark"
|
||||
: "light";
|
||||
} else {
|
||||
document.documentElement.dataset.theme = themeChoice;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to apply theme", e);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
<ak-skip-to-content></ak-skip-to-content>
|
||||
<ak-message-container alignment="bottom"></ak-message-container>
|
||||
<ak-interface-admin>
|
||||
{% include "base/placeholder.html" %}
|
||||
<ak-loading></ak-loading>
|
||||
</ak-interface-admin>
|
||||
{% endblock %}
|
||||
|
||||
@@ -12,13 +12,10 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<div class="pf-c-form">
|
||||
<form method="POST" class="pf-c-form">
|
||||
<p>{% trans message %}</p>
|
||||
|
||||
<div class="pf-c-form__group">
|
||||
<a id="ak-back-home" href="{% url 'authentik_core:root-redirect' %}" class="pf-c-button pf-m-primary pf-m-block">
|
||||
{% trans 'Go home' %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<a id="ak-back-home" href="{% url 'authentik_core:root-redirect' %}" class="pf-c-button pf-m-primary">
|
||||
{% trans 'Go home' %}
|
||||
</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
<ak-skip-to-content></ak-skip-to-content>
|
||||
<ak-message-container></ak-message-container>
|
||||
<ak-interface-user>
|
||||
{% include "base/placeholder.html" %}
|
||||
<ak-loading></ak-loading>
|
||||
</ak-interface-user>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,66 +2,82 @@
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load authentik_core %}
|
||||
|
||||
{% block head_before %}
|
||||
<link rel="prefetch" href="{{ request.brand.branding_default_flow_background_url }}" />
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/theme-dark.css' %}" media="(prefers-color-scheme: dark)">
|
||||
{% include "base/header_js.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style data-id="static-styles">
|
||||
:root {
|
||||
--ak-global--background-image: url("{{ request.brand.branding_default_flow_background_url }}");
|
||||
}
|
||||
<style>
|
||||
:root {
|
||||
--ak-flow-background: url("{{ request.brand.branding_default_flow_background_url }}");
|
||||
--pf-c-background-image--BackgroundImage: var(--ak-flow-background);
|
||||
--pf-c-background-image--BackgroundImage-2x: var(--ak-flow-background);
|
||||
--pf-c-background-image--BackgroundImage--sm: var(--ak-flow-background);
|
||||
--pf-c-background-image--BackgroundImage--sm-2x: var(--ak-flow-background);
|
||||
--pf-c-background-image--BackgroundImage--lg: var(--ak-flow-background);
|
||||
}
|
||||
/* Form with user */
|
||||
.form-control-static {
|
||||
margin-top: var(--pf-global--spacer--sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.form-control-static .avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.form-control-static img {
|
||||
margin-right: var(--pf-global--spacer--xs);
|
||||
}
|
||||
.form-control-static a {
|
||||
padding-top: var(--pf-global--spacer--xs);
|
||||
padding-bottom: var(--pf-global--spacer--xs);
|
||||
line-height: var(--pf-global--spacer--xl);
|
||||
}
|
||||
</style>
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="{% versioned_script 'dist/styles/static-%v.css' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="pf-c-background-image">
|
||||
</div>
|
||||
<ak-skip-to-content></ak-skip-to-content>
|
||||
<ak-message-container></ak-message-container>
|
||||
|
||||
<div class="pf-c-page__drawer">
|
||||
<div class="pf-c-drawer pf-m-collapsed">
|
||||
<div class="pf-c-drawer__main">
|
||||
<div class="pf-c-drawer__content">
|
||||
<div class="pf-c-drawer__body">
|
||||
<div class="pf-c-login" data-layout="stacked">
|
||||
<main class="pf-c-login__main" aria-label="Authentication form">
|
||||
<div class="pf-c-login__main-header pf-c-brand">
|
||||
<img class="branding-logo" src="{{ brand.branding_logo_url }}" alt="authentik Logo" />
|
||||
</div>
|
||||
<div class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl" data-test-id="card-title">
|
||||
{% block card_title %}
|
||||
{% endblock %}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="pf-c-login__main-body">
|
||||
{% block card %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
<footer aria-label="Site footer" class="pf-c-login__footer pf-m-dark">
|
||||
<ul class="pf-c-list pf-m-inline">
|
||||
{% for link in footer_links %}
|
||||
<li>
|
||||
<a href="{{ link.href }}">{{ link.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li>
|
||||
<span>
|
||||
{% trans 'Powered by authentik' %}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-login stacked">
|
||||
<div class="ak-login-container">
|
||||
<main class="pf-c-login__main">
|
||||
<div class="pf-c-login__main-header pf-c-brand ak-brand">
|
||||
<img src="{{ brand.branding_logo_url }}" alt="authentik Logo" />
|
||||
</div>
|
||||
</div>
|
||||
<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">
|
||||
{% block card_title %}
|
||||
{% endblock %}
|
||||
</h1>
|
||||
</header>
|
||||
<div class="pf-c-login__main-body">
|
||||
{% block card %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
<footer class="pf-c-login__footer">
|
||||
<ul class="pf-c-list pf-m-inline">
|
||||
{% for link in footer_links %}
|
||||
<li>
|
||||
<a href="{{ link.href }}">{{ link.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li>
|
||||
<span>
|
||||
{% trans 'Powered by authentik' %}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -194,6 +194,8 @@ class TestApplicationsAPI(APITestCase):
|
||||
"provider_obj": {
|
||||
"assigned_application_name": "allowed",
|
||||
"assigned_application_slug": "allowed",
|
||||
"assigned_backchannel_application_name": None,
|
||||
"assigned_backchannel_application_slug": None,
|
||||
"authentication_flow": None,
|
||||
"invalidation_flow": None,
|
||||
"authorization_flow": str(self.provider.authorization_flow.pk),
|
||||
@@ -248,6 +250,8 @@ class TestApplicationsAPI(APITestCase):
|
||||
"provider_obj": {
|
||||
"assigned_application_name": "allowed",
|
||||
"assigned_application_slug": "allowed",
|
||||
"assigned_backchannel_application_name": None,
|
||||
"assigned_backchannel_application_slug": None,
|
||||
"authentication_flow": None,
|
||||
"invalidation_flow": None,
|
||||
"authorization_flow": str(self.provider.authorization_flow.pk),
|
||||
|
||||
@@ -28,8 +28,8 @@ from authentik.core.views.interface import (
|
||||
)
|
||||
from authentik.flows.views.interface import FlowInterfaceView
|
||||
from authentik.root.asgi_middleware import AuthMiddlewareStack
|
||||
from authentik.root.messages.consumer import MessageConsumer
|
||||
from authentik.root.middleware import ChannelsLoggingMiddleware
|
||||
from authentik.root.ws.consumer import MessageConsumer
|
||||
from authentik.tenants.channels import TenantsAwareMiddleware
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
@@ -9,14 +9,9 @@ from django.http.response import HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_filters import FilterSet
|
||||
from django_filters.filters import BooleanFilter, MultipleChoiceFilter
|
||||
from django_filters.filters import BooleanFilter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import (
|
||||
OpenApiParameter,
|
||||
OpenApiResponse,
|
||||
extend_schema,
|
||||
extend_schema_field,
|
||||
)
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import (
|
||||
@@ -38,7 +33,7 @@ from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
from authentik.core.models import UserTypes
|
||||
from authentik.crypto.apps import MANAGED_KEY
|
||||
from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg
|
||||
from authentik.crypto.models import CertificateKeyPair, KeyType
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.rbac.filters import ObjectFilter, SecretKeyFilter
|
||||
@@ -55,7 +50,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
|
||||
cert_expiry = SerializerMethodField()
|
||||
cert_subject = SerializerMethodField()
|
||||
private_key_available = SerializerMethodField()
|
||||
key_type = SerializerMethodField()
|
||||
private_key_type = SerializerMethodField()
|
||||
|
||||
certificate_download_url = SerializerMethodField()
|
||||
private_key_download_url = SerializerMethodField()
|
||||
@@ -95,12 +90,14 @@ class CertificateKeyPairSerializer(ModelSerializer):
|
||||
"""Show if this keypair has a private key configured or not"""
|
||||
return instance.key_data != "" and instance.key_data is not None
|
||||
|
||||
@extend_schema_field(ChoiceField(choices=KeyType.choices, allow_null=True))
|
||||
def get_key_type(self, instance: CertificateKeyPair) -> str | None:
|
||||
"""Get the key algorithm type from the certificate's public key"""
|
||||
def get_private_key_type(self, instance: CertificateKeyPair) -> str | None:
|
||||
"""Get the private key's type, if set"""
|
||||
if not self._should_include_details:
|
||||
return None
|
||||
return instance.key_type
|
||||
key = instance.private_key
|
||||
if key:
|
||||
return key.__class__.__name__.replace("_", "").lower().replace("privatekey", "")
|
||||
return None
|
||||
|
||||
def get_certificate_download_url(self, instance: CertificateKeyPair) -> str:
|
||||
"""Get URL to download certificate"""
|
||||
@@ -164,7 +161,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
|
||||
"cert_expiry",
|
||||
"cert_subject",
|
||||
"private_key_available",
|
||||
"key_type",
|
||||
"private_key_type",
|
||||
"certificate_download_url",
|
||||
"private_key_download_url",
|
||||
"managed",
|
||||
@@ -201,31 +198,12 @@ class CertificateKeyPairFilter(FilterSet):
|
||||
label="Only return certificate-key pairs with keys", method="filter_has_key"
|
||||
)
|
||||
|
||||
key_type = MultipleChoiceFilter(
|
||||
choices=KeyType.choices,
|
||||
label="Filter by key algorithm type",
|
||||
method="filter_key_type",
|
||||
)
|
||||
|
||||
def filter_has_key(self, queryset, name, value): # pragma: no cover
|
||||
"""Only return certificate-key pairs with keys"""
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.exclude(key_data__exact="")
|
||||
|
||||
def filter_key_type(self, queryset, name, value): # pragma: no cover
|
||||
"""Filter certificates by key type using the public key from the certificate"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
# value is a list of KeyType enum values from MultipleChoiceFilter
|
||||
filtered_pks = []
|
||||
for cert in queryset:
|
||||
if cert.key_type in value:
|
||||
filtered_pks.append(cert.pk)
|
||||
|
||||
return queryset.filter(pk__in=filtered_pks)
|
||||
|
||||
class Meta:
|
||||
model = CertificateKeyPair
|
||||
fields = ["name", "managed"]
|
||||
@@ -250,17 +228,6 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
||||
required=False,
|
||||
description="Only return certificate-key pairs with keys",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"key_type",
|
||||
OpenApiTypes.STR,
|
||||
required=False,
|
||||
many=True,
|
||||
enum=[choice[0] for choice in KeyType.choices],
|
||||
description=(
|
||||
"Filter by key algorithm type (RSA, EC, DSA, etc). "
|
||||
"Can be specified multiple times (e.g. '?key_type=rsa&key_type=ec')"
|
||||
),
|
||||
),
|
||||
OpenApiParameter("include_details", bool, default=True),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from dramatiq.broker import get_broker
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.utils.time import fqdn_rand
|
||||
@@ -72,12 +70,6 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
||||
},
|
||||
)
|
||||
|
||||
@ManagedAppConfig.reconcile_global
|
||||
def tasks_middlewares(self):
|
||||
from authentik.crypto.tasks import CertificateWatcherMiddleware
|
||||
|
||||
get_broker().add_middleware(CertificateWatcherMiddleware())
|
||||
|
||||
@property
|
||||
def tenant_schedule_specs(self) -> list[ScheduleSpec]:
|
||||
from authentik.crypto.tasks import certificate_discovery
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
|
||||
from binascii import hexlify
|
||||
from hashlib import md5
|
||||
from ssl import PEM_FOOTER, PEM_HEADER
|
||||
from textwrap import wrap
|
||||
from uuid import uuid4
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric.dsa import DSAPublicKey
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
|
||||
from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PublicKey
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes, PublicKeyTypes
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from cryptography.x509 import Certificate, load_pem_x509_certificate
|
||||
@@ -25,14 +22,9 @@ from authentik.lib.models import CreatedUpdatedModel, SerializerModel
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class KeyType(models.TextChoices):
|
||||
"""Cryptographic key algorithm types"""
|
||||
|
||||
RSA = "rsa", _("RSA")
|
||||
EC = "ec", _("Elliptic Curve")
|
||||
DSA = "dsa", _("DSA")
|
||||
ED25519 = "ed25519", _("Ed25519")
|
||||
ED448 = "ed448", _("Ed448")
|
||||
def format_cert(raw_pam: str) -> str:
|
||||
"""Format a PEM certificate that is either missing its header/footer or is in a single line"""
|
||||
return "\n".join([PEM_HEADER, *wrap(raw_pam.replace("\n", ""), 64), PEM_FOOTER])
|
||||
|
||||
|
||||
def fingerprint_sha256(cert: Certificate) -> str:
|
||||
@@ -118,22 +110,6 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||
else ""
|
||||
) # nosec
|
||||
|
||||
@property
|
||||
def key_type(self) -> str | None:
|
||||
"""Get the key algorithm type from the certificate's public key"""
|
||||
public_key = self.certificate.public_key()
|
||||
if isinstance(public_key, RSAPublicKey):
|
||||
return KeyType.RSA
|
||||
if isinstance(public_key, EllipticCurvePublicKey):
|
||||
return KeyType.EC
|
||||
if isinstance(public_key, DSAPublicKey):
|
||||
return KeyType.DSA
|
||||
if isinstance(public_key, Ed25519PublicKey):
|
||||
return KeyType.ED25519
|
||||
if isinstance(public_key, Ed448PublicKey):
|
||||
return KeyType.ED448
|
||||
return None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Certificate-Key Pair {self.name}"
|
||||
|
||||
|
||||
@@ -2,29 +2,17 @@
|
||||
|
||||
from glob import glob
|
||||
from pathlib import Path
|
||||
from sys import platform
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from cryptography.x509.base import load_pem_x509_certificate
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from dramatiq.actor import actor
|
||||
from dramatiq.middleware import Middleware
|
||||
from structlog.stdlib import get_logger
|
||||
from watchdog.events import (
|
||||
FileCreatedEvent,
|
||||
FileModifiedEvent,
|
||||
FileSystemEvent,
|
||||
FileSystemEventHandler,
|
||||
)
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.tasks.middleware import CurrentTask
|
||||
from authentik.tasks.schedules.models import Schedule
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -47,65 +35,6 @@ def ensure_certificate_valid(body: str):
|
||||
return body
|
||||
|
||||
|
||||
class CertificateWatcherMiddleware(Middleware):
|
||||
"""Middleware to start certificate file watcher"""
|
||||
|
||||
def start_certificate_watcher(self):
|
||||
"""Start certificate file watcher"""
|
||||
observer = Observer()
|
||||
kwargs = {}
|
||||
if platform.startswith("linux"):
|
||||
kwargs["event_filter"] = (FileCreatedEvent, FileModifiedEvent)
|
||||
observer.schedule(
|
||||
CertificateEventHandler(),
|
||||
CONFIG.get("cert_discovery_dir"),
|
||||
recursive=True,
|
||||
**kwargs,
|
||||
)
|
||||
observer.start()
|
||||
|
||||
def after_worker_boot(self, broker, worker):
|
||||
if not settings.TEST:
|
||||
self.start_certificate_watcher()
|
||||
|
||||
|
||||
class CertificateEventHandler(FileSystemEventHandler):
|
||||
"""Event handler for certificate file events"""
|
||||
|
||||
# We only ever get creation and modification events.
|
||||
# See the creation of the Observer instance above for the event filtering.
|
||||
|
||||
# Even though we filter to only get file events, we might still get
|
||||
# directory events as some implementations such as inotify do not support
|
||||
# filtering on file/directory.
|
||||
|
||||
def dispatch(self, event: FileSystemEvent) -> None:
|
||||
"""Call specific event handler method. Ignores directory changes."""
|
||||
if event.is_directory:
|
||||
return None
|
||||
return super().dispatch(event)
|
||||
|
||||
def on_created(self, event: FileSystemEvent):
|
||||
"""Process certificate file creation"""
|
||||
LOGGER.debug(
|
||||
"Certificate file created, triggering discovery",
|
||||
file=event.src_path,
|
||||
)
|
||||
for tenant in Tenant.objects.filter(ready=True):
|
||||
with tenant:
|
||||
Schedule.dispatch_by_actor(certificate_discovery)
|
||||
|
||||
def on_modified(self, event: FileSystemEvent):
|
||||
"""Process certificate file modification"""
|
||||
LOGGER.debug(
|
||||
"Certificate file modified, triggering discovery",
|
||||
file=event.src_path,
|
||||
)
|
||||
for tenant in Tenant.objects.filter(ready=True):
|
||||
with tenant:
|
||||
Schedule.dispatch_by_actor(certificate_discovery)
|
||||
|
||||
|
||||
@actor(description=_("Discover, import and update certificates from the filesystem."))
|
||||
def certificate_discovery():
|
||||
self = CurrentTask.get_task()
|
||||
@@ -137,35 +66,12 @@ def certificate_discovery():
|
||||
except (OSError, ValueError) as exc:
|
||||
LOGGER.warning("Failed to open file or invalid format", exc=exc, file=path)
|
||||
for name, cert_data in certs.items():
|
||||
# First, try to find by filename-based managed field
|
||||
cert = CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % name).first()
|
||||
|
||||
# If not found by filename and we have a private key, check for existing key match
|
||||
if not cert and name in private_keys:
|
||||
existing_with_key = (
|
||||
CertificateKeyPair.objects.filter(
|
||||
managed__startswith="goauthentik.io/crypto/discovered/",
|
||||
key_data=private_keys[name],
|
||||
)
|
||||
.exclude(key_data="")
|
||||
.first()
|
||||
)
|
||||
if existing_with_key:
|
||||
cert = existing_with_key
|
||||
# Update name and managed field to reflect the new filename
|
||||
if cert.name != name:
|
||||
cert.name = name
|
||||
cert.managed = MANAGED_DISCOVERED % name
|
||||
cert.save()
|
||||
|
||||
# Create new certificate if not found
|
||||
if not cert:
|
||||
cert = CertificateKeyPair(
|
||||
name=name,
|
||||
managed=MANAGED_DISCOVERED % name,
|
||||
)
|
||||
|
||||
# Update certificate data if changed
|
||||
dirty = False
|
||||
if cert.certificate_data != cert_data:
|
||||
cert.certificate_data = cert_data
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from json import loads
|
||||
from os import makedirs
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from cryptography.x509.extensions import SubjectAlternativeName
|
||||
@@ -320,8 +319,6 @@ class TestCrypto(APITestCase):
|
||||
|
||||
def test_discovery(self):
|
||||
"""Test certificate discovery"""
|
||||
# This test generates 2 separate cert/key combinations
|
||||
# and verifies they both import properly
|
||||
name = generate_id()
|
||||
builder = CertificateBuilder(name)
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -330,15 +327,6 @@ class TestCrypto(APITestCase):
|
||||
subject_alt_names=[],
|
||||
validity_days=3,
|
||||
)
|
||||
|
||||
name2 = generate_id()
|
||||
builder2 = CertificateBuilder(name2)
|
||||
with self.assertRaises(ValueError):
|
||||
builder2.save()
|
||||
builder2.build(
|
||||
subject_alt_names=[],
|
||||
validity_days=3,
|
||||
)
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
with open(f"{temp_dir}/foo.pem", "w+", encoding="utf-8") as _cert:
|
||||
_cert.write(builder.certificate)
|
||||
@@ -346,9 +334,9 @@ class TestCrypto(APITestCase):
|
||||
_key.write(builder.private_key)
|
||||
makedirs(f"{temp_dir}/foo.bar", exist_ok=True)
|
||||
with open(f"{temp_dir}/foo.bar/fullchain.pem", "w+", encoding="utf-8") as _cert:
|
||||
_cert.write(builder2.certificate)
|
||||
_cert.write(builder.certificate)
|
||||
with open(f"{temp_dir}/foo.bar/privkey.pem", "w+", encoding="utf-8") as _key:
|
||||
_key.write(builder2.private_key)
|
||||
_key.write(builder.private_key)
|
||||
with CONFIG.patch("cert_discovery_dir", temp_dir):
|
||||
certificate_discovery.send()
|
||||
keypair: CertificateKeyPair = CertificateKeyPair.objects.filter(
|
||||
@@ -360,58 +348,3 @@ class TestCrypto(APITestCase):
|
||||
self.assertTrue(
|
||||
CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % "foo.bar").exists()
|
||||
)
|
||||
|
||||
def test_discovery_updating_same_private_key(self):
|
||||
"""Test certificate discovery updating certs with matching private keys"""
|
||||
name = generate_id()
|
||||
builder = CertificateBuilder(name)
|
||||
builder.build(
|
||||
subject_alt_names=[],
|
||||
validity_days=3,
|
||||
)
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
# First discovery: write cert as "original"
|
||||
with open(f"{temp_dir}/original.pem", "w+", encoding="utf-8") as _cert:
|
||||
_cert.write(builder.certificate)
|
||||
with open(f"{temp_dir}/original.key", "w+", encoding="utf-8") as _key:
|
||||
_key.write(builder.private_key)
|
||||
|
||||
with CONFIG.patch("cert_discovery_dir", temp_dir):
|
||||
certificate_discovery.send()
|
||||
|
||||
# Verify "original" cert was created
|
||||
original = CertificateKeyPair.objects.filter(
|
||||
managed=MANAGED_DISCOVERED % "original"
|
||||
).first()
|
||||
self.assertIsNotNone(original)
|
||||
self.assertEqual(original.name, "original")
|
||||
self.assertIsNotNone(original.private_key)
|
||||
|
||||
# Second discovery: write same cert/key as "renamed"
|
||||
Path(f"{temp_dir}/original.pem").unlink()
|
||||
Path(f"{temp_dir}/original.key").unlink()
|
||||
|
||||
with open(f"{temp_dir}/renamed.pem", "w+", encoding="utf-8") as _cert:
|
||||
_cert.write(builder.certificate)
|
||||
with open(f"{temp_dir}/renamed.key", "w+", encoding="utf-8") as _key:
|
||||
_key.write(builder.private_key)
|
||||
|
||||
with CONFIG.patch("cert_discovery_dir", temp_dir):
|
||||
certificate_discovery.send()
|
||||
|
||||
# Verify the cert was updated
|
||||
renamed = CertificateKeyPair.objects.filter(
|
||||
managed=MANAGED_DISCOVERED % "renamed"
|
||||
).first()
|
||||
self.assertIsNotNone(renamed, "Renamed certificate should exist")
|
||||
self.assertEqual(renamed.name, "renamed")
|
||||
self.assertEqual(renamed.pk, original.pk, "Should be same database object")
|
||||
|
||||
# Verify no new cert was created
|
||||
final_count = CertificateKeyPair.objects.filter(
|
||||
managed__startswith="goauthentik.io/crypto/discovered/"
|
||||
).count()
|
||||
self.assertEqual(
|
||||
1, final_count, "Should not create duplicate cert for same private key"
|
||||
)
|
||||
|
||||
@@ -37,8 +37,6 @@ class GoogleWorkspaceProviderSerializer(EnterpriseRequiredMixin, ProviderSeriali
|
||||
"user_delete_action",
|
||||
"group_delete_action",
|
||||
"default_group_email_domain",
|
||||
"sync_page_size",
|
||||
"sync_page_timeout",
|
||||
"dry_run",
|
||||
]
|
||||
extra_kwargs = {}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-21 12:35
|
||||
|
||||
import authentik.lib.utils.time
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_google_workspace", "0004_googleworkspaceprovider_dry_run"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="googleworkspaceprovider",
|
||||
name="sync_page_size",
|
||||
field=models.PositiveIntegerField(
|
||||
default=100,
|
||||
help_text="Controls the number of objects synced in a single task",
|
||||
validators=[django.core.validators.MinValueValidator(1)],
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="googleworkspaceprovider",
|
||||
name="sync_page_timeout",
|
||||
field=models.TextField(
|
||||
default="minutes=30",
|
||||
help_text="Timeout for synchronization of a single page",
|
||||
validators=[authentik.lib.utils.time.timedelta_string_validator],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -36,8 +36,6 @@ class MicrosoftEntraProviderSerializer(EnterpriseRequiredMixin, ProviderSerializ
|
||||
"filter_group",
|
||||
"user_delete_action",
|
||||
"group_delete_action",
|
||||
"sync_page_size",
|
||||
"sync_page_timeout",
|
||||
"dry_run",
|
||||
]
|
||||
extra_kwargs = {}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-21 12:35
|
||||
|
||||
import authentik.lib.utils.time
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_microsoft_entra", "0003_microsoftentraprovider_dry_run"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="microsoftentraprovider",
|
||||
name="sync_page_size",
|
||||
field=models.PositiveIntegerField(
|
||||
default=100,
|
||||
help_text="Controls the number of objects synced in a single task",
|
||||
validators=[django.core.validators.MinValueValidator(1)],
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="microsoftentraprovider",
|
||||
name="sync_page_timeout",
|
||||
field=models.TextField(
|
||||
default="minutes=30",
|
||||
help_text="Timeout for synchronization of a single page",
|
||||
validators=[authentik.lib.utils.time.timedelta_string_validator],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -42,6 +42,8 @@ def send_ssf_events(
|
||||
for stream in Stream.objects.filter(**stream_filter):
|
||||
event_data = stream.prepare_event_payload(event_type, data, **extra_data)
|
||||
events_data[stream.uuid] = event_data
|
||||
if not events_data:
|
||||
return
|
||||
ssf_events_dispatch.send(events_data)
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from authentik.stages.authenticator.models import Device
|
||||
|
||||
|
||||
class AuthenticatorEndpointGDTCStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
||||
"""Verify Google Chrome Device Trust connection for the user's browser."""
|
||||
"""Setup Google Chrome Device Trust connection"""
|
||||
|
||||
credentials = models.JSONField()
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from binascii import hexlify
|
||||
from enum import IntFlag, auto
|
||||
from urllib.parse import unquote_plus
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
@@ -17,7 +18,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.models import User
|
||||
from authentik.crypto.models import CertificateKeyPair, fingerprint_sha256
|
||||
from authentik.crypto.models import CertificateKeyPair, fingerprint_sha256, format_cert
|
||||
from authentik.enterprise.stages.mtls.models import (
|
||||
CertAttributes,
|
||||
MutualTLSStage,
|
||||
@@ -43,14 +44,28 @@ HEADER_OUTPOST_FORWARDED = "X-Authentik-Outpost-Certificate"
|
||||
PLAN_CONTEXT_CERTIFICATE = "certificate"
|
||||
|
||||
|
||||
class ParseOptions(IntFlag):
|
||||
|
||||
# URL unquote the string
|
||||
UNQUOTE = auto()
|
||||
# Re-add PEM Header & footer, and chunk it into 64 character lines
|
||||
FORMAT = auto()
|
||||
|
||||
|
||||
class MTLSStageView(ChallengeStageView):
|
||||
|
||||
def __parse_single_cert(self, raw: str | None) -> list[Certificate]:
|
||||
def __parse_single_cert(self, raw: str | None, *options: ParseOptions) -> list[Certificate]:
|
||||
"""Helper to parse a single certificate"""
|
||||
if not raw:
|
||||
return []
|
||||
for opt in options:
|
||||
match opt:
|
||||
case ParseOptions.FORMAT:
|
||||
raw = format_cert(raw)
|
||||
case ParseOptions.UNQUOTE:
|
||||
raw = unquote_plus(raw)
|
||||
try:
|
||||
cert = load_pem_x509_certificate(unquote_plus(raw).encode())
|
||||
cert = load_pem_x509_certificate(raw.encode())
|
||||
return [cert]
|
||||
except ValueError as exc:
|
||||
self.logger.info("Failed to parse certificate", exc=exc)
|
||||
@@ -59,6 +74,7 @@ class MTLSStageView(ChallengeStageView):
|
||||
def _parse_cert_xfcc(self) -> list[Certificate]:
|
||||
"""Parse certificates in the format given to us in
|
||||
the format of the authentik router/envoy"""
|
||||
# https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-forwarded-client-cert
|
||||
xfcc_raw = self.request.headers.get(HEADER_PROXY_FORWARDED)
|
||||
if not xfcc_raw:
|
||||
return []
|
||||
@@ -68,18 +84,26 @@ class MTLSStageView(ChallengeStageView):
|
||||
raw_cert = {k.split("=")[0]: k.split("=")[1] for k in el}
|
||||
if "Cert" not in raw_cert:
|
||||
continue
|
||||
certs.extend(self.__parse_single_cert(raw_cert["Cert"]))
|
||||
certs.extend(self.__parse_single_cert(raw_cert["Cert"], ParseOptions.UNQUOTE))
|
||||
return certs
|
||||
|
||||
def _parse_cert_nginx(self) -> list[Certificate]:
|
||||
"""Parse certificates in the format nginx-ingress gives to us"""
|
||||
# https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#client-certificate-authentication
|
||||
# https://github.com/kubernetes/ingress-nginx/blob/78f593b24494a0674b362faf551079f06d71b5a9/rootfs/etc/nginx/template/nginx.tmpl#L1096
|
||||
sslcc_raw = self.request.headers.get(HEADER_NGINX_FORWARDED)
|
||||
return self.__parse_single_cert(sslcc_raw)
|
||||
return self.__parse_single_cert(sslcc_raw, ParseOptions.UNQUOTE)
|
||||
|
||||
def _parse_cert_traefik(self) -> list[Certificate]:
|
||||
"""Parse certificates in the format traefik gives to us"""
|
||||
# https://doc.traefik.io/traefik/reference/routing-configuration/http/middlewares/passtlsclientcert/
|
||||
ftcc_raw = self.request.headers.get(HEADER_TRAEFIK_FORWARDED)
|
||||
return self.__parse_single_cert(ftcc_raw)
|
||||
if not ftcc_raw:
|
||||
return []
|
||||
certs = []
|
||||
for cert in ftcc_raw.split(","):
|
||||
certs.extend(self.__parse_single_cert(cert, ParseOptions.UNQUOTE, ParseOptions.FORMAT))
|
||||
return certs
|
||||
|
||||
def _parse_cert_outpost(self) -> list[Certificate]:
|
||||
"""Parse certificates in the format outposts give to us. Also authenticates
|
||||
@@ -92,7 +116,7 @@ class MTLSStageView(ChallengeStageView):
|
||||
) and not user.has_perm("authentik_stages_mtls.pass_outpost_certificate"):
|
||||
return []
|
||||
outpost_raw = self.request.headers.get(HEADER_OUTPOST_FORWARDED)
|
||||
return self.__parse_single_cert(outpost_raw)
|
||||
return self.__parse_single_cert(outpost_raw, ParseOptions.UNQUOTE)
|
||||
|
||||
def get_authorities(self) -> list[CertificateKeyPair] | None:
|
||||
# We can't access `certificate_authorities` on `self.executor.current_stage`, as that would
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from ssl import PEM_FOOTER, PEM_HEADER
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
@@ -51,6 +52,10 @@ class MTLSStageTests(FlowTestCase):
|
||||
User.objects.filter(username="client").delete()
|
||||
self.cert_user = create_test_user(username="client")
|
||||
|
||||
def _format_traefik(self, cert: str | None = None):
|
||||
cert = cert if cert else self.client_cert
|
||||
return quote_plus(cert.replace(PEM_HEADER, "").replace(PEM_FOOTER, "").replace("\n", ""))
|
||||
|
||||
def test_parse_xfcc(self):
|
||||
"""Test authentik Proxy/Envoy's XFCC format"""
|
||||
with self.assertFlowFinishes() as plan:
|
||||
@@ -78,7 +83,7 @@ class MTLSStageTests(FlowTestCase):
|
||||
with self.assertFlowFinishes() as plan:
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
|
||||
@@ -138,7 +143,9 @@ class MTLSStageTests(FlowTestCase):
|
||||
with self.assertFlowFinishes() as plan:
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(cert.certificate_data)},
|
||||
headers={
|
||||
"X-Forwarded-TLS-Client-Cert": self._format_traefik(cert.certificate_data)
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
|
||||
@@ -149,7 +156,7 @@ class MTLSStageTests(FlowTestCase):
|
||||
User.objects.filter(username="client").delete()
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
|
||||
@@ -163,7 +170,7 @@ class MTLSStageTests(FlowTestCase):
|
||||
with self.assertFlowFinishes() as plan:
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
|
||||
@@ -176,7 +183,7 @@ class MTLSStageTests(FlowTestCase):
|
||||
self.stage.save()
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
|
||||
@@ -187,7 +194,7 @@ class MTLSStageTests(FlowTestCase):
|
||||
self.stage.save()
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
|
||||
@@ -209,7 +216,7 @@ class MTLSStageTests(FlowTestCase):
|
||||
with self.assertFlowFinishes() as plan:
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)},
|
||||
headers={"X-Forwarded-TLS-Client-Cert": self._format_traefik()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertStageRedirects(res, reverse("authentik_core:root-redirect"))
|
||||
|
||||
@@ -10,7 +10,7 @@ from authentik.lib.utils.time import timedelta_string_validator
|
||||
|
||||
|
||||
class SourceStage(Stage):
|
||||
"""Suspend the current flow execution and send the user to a federated source,
|
||||
"""Suspend the current flow execution and send the user to a source,
|
||||
after which this flow execution is resumed."""
|
||||
|
||||
source = models.ForeignKey("authentik_core.Source", on_delete=models.CASCADE)
|
||||
|
||||
@@ -4,6 +4,7 @@ from prometheus_client import Gauge, Histogram
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
from authentik.lib.utils.reflection import all_subclasses
|
||||
from authentik.tenants.flags import Flag
|
||||
|
||||
GAUGE_FLOWS_CACHED = Gauge(
|
||||
"authentik_flows_cached",
|
||||
@@ -22,6 +23,12 @@ HIST_FLOWS_PLAN_TIME = Histogram(
|
||||
)
|
||||
|
||||
|
||||
class RefreshOtherFlowsAfterAuthentication(Flag[bool], key="flows_refresh_others"):
|
||||
|
||||
default = False
|
||||
visibility = "public"
|
||||
|
||||
|
||||
class AuthentikFlowsConfig(ManagedAppConfig):
|
||||
"""authentik flows app config"""
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<meta name="sentry-trace" content="{{ sentry_trace }}" />
|
||||
<link rel="prefetch" href="{{ flow_background_url }}" />
|
||||
{% include "base/header_js.html" %}
|
||||
<style data-id="flow-sfe">
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
|
||||
@@ -1,74 +1,35 @@
|
||||
{% extends "base/skeleton.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load authentik_core %}
|
||||
|
||||
|
||||
{% block head_before %}
|
||||
{{ block.super }}
|
||||
<link rel="prefetch" href="{{ flow_background_url }}" />
|
||||
{% if flow.compatibility_mode and not inspector %}
|
||||
<script data-id="shady-dom">ShadyDOM = { force: true };</script>
|
||||
<script>ShadyDOM = { force: true };</script>
|
||||
{% endif %}
|
||||
{% include "base/header_js.html" %}
|
||||
<script data-id="flow-config">
|
||||
"use strict";
|
||||
|
||||
window.authentik.flow = {
|
||||
"layout": "{{ flow.layout }}",
|
||||
};
|
||||
<script>
|
||||
window.authentik.flow = {
|
||||
"layout": "{{ flow.layout }}",
|
||||
};
|
||||
</script>
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="{% versioned_script 'dist/styles/static-%v.css' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script>
|
||||
<style data-id="flow-css">
|
||||
:root {
|
||||
--ak-global--background-image: url("{{ flow_background_url }}");
|
||||
}
|
||||
<style>
|
||||
:root {
|
||||
--ak-flow-background: url("{{ flow_background_url }}");
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<ak-skip-to-content></ak-skip-to-content>
|
||||
<ak-message-container></ak-message-container>
|
||||
|
||||
<ak-locale-context>
|
||||
<div class="pf-c-page__drawer">
|
||||
<div class="pf-c-drawer pf-m-collapsed" id="flow-drawer">
|
||||
<div class="pf-c-drawer__main">
|
||||
<div class="pf-c-drawer__content">
|
||||
<div class="pf-c-drawer__body">
|
||||
<ak-flow-executor
|
||||
slug="{{ flow.slug }}"
|
||||
class="pf-c-login"
|
||||
data-layout="{{ flow.layout|default:'stacked' }}"
|
||||
>
|
||||
{% include "base/placeholder.html" %}
|
||||
|
||||
<ak-brand-links
|
||||
slot="footer"
|
||||
exportparts="list:brand-links-list, list-item:brand-links-list-item"
|
||||
role="contentinfo"
|
||||
aria-label="{% trans 'Site footer' %}"
|
||||
class="pf-c-login__footer {% if flow.layout == 'stacked' %}pf-m-dark{% endif %}"
|
||||
></ak-brand-links>
|
||||
</ak-flow-executor>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ak-flow-inspector
|
||||
id="flow-inspector"
|
||||
data-registration="lazy"
|
||||
class="pf-c-drawer__panel pf-m-width-33"
|
||||
slug="{{ flow.slug }}"
|
||||
></ak-flow-inspector>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ak-locale-context>
|
||||
<ak-flow-executor flowSlug="{{ flow.slug }}">
|
||||
<ak-loading></ak-loading>
|
||||
</ak-flow-executor>
|
||||
{% endblock %}
|
||||
|
||||
@@ -41,7 +41,7 @@ ARG_SANITIZE = re.compile(r"[:.-]")
|
||||
|
||||
|
||||
def sanitize_arg(arg_name: str) -> str:
|
||||
return re.sub(ARG_SANITIZE, "_", arg_name)
|
||||
return re.sub(ARG_SANITIZE, "_", slugify(arg_name))
|
||||
|
||||
|
||||
class BaseEvaluator:
|
||||
@@ -299,7 +299,9 @@ class BaseEvaluator:
|
||||
|
||||
def wrap_expression(self, expression: str) -> str:
|
||||
"""Wrap expression in a function, call it, and save the result as `result`"""
|
||||
handler_signature = ",".join(sanitize_arg(x) for x in self._context.keys())
|
||||
handler_signature = ",".join(
|
||||
[x for x in [sanitize_arg(x) for x in self._context.keys()] if x]
|
||||
)
|
||||
full_expression = ""
|
||||
full_expression += f"def handler({handler_signature}):\n"
|
||||
full_expression += indent(expression, " ")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Sync constants"""
|
||||
|
||||
PAGE_SIZE = 100
|
||||
PAGE_TIMEOUT_MS = 60 * 60 * 0.5 * 1000 # Half an hour
|
||||
HTTP_CONFLICT = 409
|
||||
HTTP_NO_CONTENT = 204
|
||||
HTTP_SERVICE_UNAVAILABLE = 503
|
||||
|
||||
@@ -2,15 +2,15 @@ from typing import Any, Self
|
||||
|
||||
import pglock
|
||||
from django.core.paginator import Paginator
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import connection, models
|
||||
from django.db.models import Model, QuerySet, TextChoices
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from dramatiq.actor import Actor
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.lib.sync.outgoing import PAGE_SIZE, PAGE_TIMEOUT_MS
|
||||
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
|
||||
from authentik.lib.utils.time import fqdn_rand, timedelta_from_string, timedelta_string_validator
|
||||
from authentik.lib.utils.time import fqdn_rand
|
||||
from authentik.tasks.schedules.common import ScheduleSpec
|
||||
from authentik.tasks.schedules.models import ScheduledModel
|
||||
|
||||
@@ -27,17 +27,6 @@ class OutgoingSyncDeleteAction(TextChoices):
|
||||
class OutgoingSyncProvider(ScheduledModel, Model):
|
||||
"""Base abstract models for providers implementing outgoing sync"""
|
||||
|
||||
sync_page_size = models.PositiveIntegerField(
|
||||
help_text=_("Controls the number of objects synced in a single task"),
|
||||
default=100,
|
||||
validators=[MinValueValidator(1)],
|
||||
)
|
||||
sync_page_timeout = models.TextField(
|
||||
help_text=_("Timeout for synchronization of a single page"),
|
||||
default="minutes=30",
|
||||
validators=[timedelta_string_validator],
|
||||
)
|
||||
|
||||
dry_run = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
@@ -57,12 +46,11 @@ class OutgoingSyncProvider(ScheduledModel, Model):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_paginator[T: User | Group](self, type: type[T]) -> Paginator:
|
||||
return Paginator(self.get_object_qs(type), self.sync_page_size)
|
||||
return Paginator(self.get_object_qs(type), PAGE_SIZE)
|
||||
|
||||
def get_object_sync_time_limit_ms[T: User | Group](self, type: type[T]) -> int:
|
||||
num_pages: int = self.get_paginator(type).num_pages
|
||||
page_timeout_ms = timedelta_from_string(self.sync_page_timeout).total_seconds() * 1000
|
||||
return int(num_pages * page_timeout_ms * 1.5)
|
||||
return int(num_pages * PAGE_TIMEOUT_MS * 1.5)
|
||||
|
||||
def get_sync_time_limit_ms(self) -> int:
|
||||
return int(
|
||||
|
||||
@@ -28,6 +28,8 @@ def register_signals(
|
||||
# This primarily happens during user login
|
||||
if sender == User and update_fields == {"last_login"}:
|
||||
return
|
||||
if not provider_type.objects.exists():
|
||||
return
|
||||
task_sync_direct_dispatch.send(
|
||||
class_to_path(instance.__class__),
|
||||
instance.pk,
|
||||
@@ -39,6 +41,8 @@ def register_signals(
|
||||
|
||||
def model_pre_delete(sender: type[Model], instance: User | Group, **_):
|
||||
"""Pre-delete handler"""
|
||||
if not provider_type.objects.exists():
|
||||
return
|
||||
task_sync_direct_dispatch.send(
|
||||
class_to_path(instance.__class__),
|
||||
instance.pk,
|
||||
@@ -54,6 +58,8 @@ def register_signals(
|
||||
"""Sync group membership"""
|
||||
if action not in ["post_add", "post_remove"]:
|
||||
return
|
||||
if not provider_type.objects.exists():
|
||||
return
|
||||
task_sync_m2m_dispatch.send(instance.pk, action, list(pk_set), reverse)
|
||||
|
||||
m2m_changed.connect(model_m2m_changed, User.ak_groups.through, dispatch_uid=uid, weak=False)
|
||||
|
||||
@@ -9,6 +9,7 @@ from structlog.stdlib import BoundLogger, get_logger
|
||||
from authentik.core.expression.exceptions import SkipObjectException
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.events.utils import sanitize_item
|
||||
from authentik.lib.sync.outgoing import PAGE_SIZE, PAGE_TIMEOUT_MS
|
||||
from authentik.lib.sync.outgoing.base import Direction
|
||||
from authentik.lib.sync.outgoing.exceptions import (
|
||||
BadRequestSyncException,
|
||||
@@ -19,7 +20,6 @@ from authentik.lib.sync.outgoing.exceptions import (
|
||||
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
|
||||
from authentik.lib.utils.errors import exception_to_dict
|
||||
from authentik.lib.utils.reflection import class_to_path, path_to_class
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.tasks.middleware import CurrentTask
|
||||
from authentik.tasks.models import Task
|
||||
|
||||
@@ -44,11 +44,10 @@ class SyncTasks:
|
||||
**options,
|
||||
):
|
||||
tasks = []
|
||||
time_limit = timedelta_from_string(provider.sync_page_timeout).total_seconds() * 1000
|
||||
for page in paginator.page_range:
|
||||
page_sync = sync_objects.message_with_options(
|
||||
args=(class_to_path(object_type), page, provider.pk),
|
||||
time_limit=time_limit,
|
||||
time_limit=PAGE_TIMEOUT_MS,
|
||||
# Assign tasks to the same schedule as the current one
|
||||
rel_obj=current_task.rel_obj,
|
||||
uid=f"{provider.name}:{object_type._meta.model_name}:{page}",
|
||||
@@ -140,10 +139,7 @@ class SyncTasks:
|
||||
client = provider.client_for_model(_object_type)
|
||||
except TransientSyncException:
|
||||
return
|
||||
paginator = Paginator(
|
||||
provider.get_object_qs(_object_type).filter(**filter),
|
||||
provider.sync_page_size,
|
||||
)
|
||||
paginator = Paginator(provider.get_object_qs(_object_type).filter(**filter), PAGE_SIZE)
|
||||
if client.can_discover:
|
||||
self.logger.debug("starting discover")
|
||||
client.discover()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test Evaluator base functions"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import RequestFactory, TestCase
|
||||
@@ -239,3 +240,18 @@ class TestEvaluator(TestCase):
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
evaluator.evaluate("return ak_send_email(123, 'Test', body='Body')")
|
||||
self.assertIn("Address must be a string or list of strings", str(cm.exception))
|
||||
|
||||
def test_expr_arg_escape(self):
|
||||
"""Test escaping of arguments"""
|
||||
eval = BaseEvaluator()
|
||||
eval._context = {
|
||||
'z=getattr(getattr(__import__("os"), "popen")("id > /tmp/test"), "read")()': "bar",
|
||||
"@@": "baz",
|
||||
"{{": "baz",
|
||||
"aa@@": "baz",
|
||||
}
|
||||
res = eval.evaluate("return locals()")
|
||||
self.assertEqual(
|
||||
res, {"zgetattrgetattr__import__os_popenid_tmptest_read": "bar", "aa": "baz"}
|
||||
)
|
||||
self.assertFalse(Path("/tmp/test").exists()) # nosec
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"""authentik database utilities"""
|
||||
|
||||
import gc
|
||||
from collections.abc import Generator
|
||||
|
||||
from django.db import reset_queries
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models import Model, QuerySet
|
||||
|
||||
|
||||
def chunked_queryset(queryset: QuerySet, chunk_size: int = 1_000):
|
||||
def chunked_queryset[T: Model](queryset: QuerySet[T], chunk_size: int = 1_000) -> Generator[T]:
|
||||
if not queryset.exists():
|
||||
return []
|
||||
|
||||
def get_chunks(qs: QuerySet):
|
||||
def get_chunks(qs: QuerySet) -> Generator[QuerySet[T]]:
|
||||
qs = qs.order_by("pk")
|
||||
pks = qs.values_list("pk", flat=True)
|
||||
start_pk = pks[0]
|
||||
|
||||
@@ -47,7 +47,9 @@ class OutpostSerializer(ModelSerializer):
|
||||
)
|
||||
providers_obj = ProviderSerializer(source="providers", many=True, read_only=True)
|
||||
service_connection_obj = ServiceConnectionSerializer(
|
||||
source="service_connection", read_only=True
|
||||
source="service_connection",
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
)
|
||||
refresh_interval_s = SerializerMethodField()
|
||||
|
||||
|
||||
@@ -203,6 +203,12 @@ class DockerController(BaseController):
|
||||
"labels": self._get_labels(),
|
||||
"restart_policy": {"Name": "unless-stopped"},
|
||||
"network": self.outpost.config.docker_network,
|
||||
"healthcheck": {
|
||||
"test": ["CMD", f"/{self.outpost.type}", "healthcheck"],
|
||||
"interval": 5 * 1_000 * 1_000_000,
|
||||
"retries": 20,
|
||||
"start_period": 3 * 1_000 * 1_000_000,
|
||||
},
|
||||
}
|
||||
if self.outpost.config.docker_map_ports:
|
||||
container_args["ports"] = {
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<div class="pf-c-form">
|
||||
<form class="pf-c-form">
|
||||
{% csrf_token %}
|
||||
{% if user.is_authenticated %}
|
||||
<div class="pf-c-form__group">
|
||||
<div class="form-control-static">
|
||||
@@ -28,7 +29,7 @@
|
||||
{% endif %}
|
||||
<div class="pf-c-form__group">
|
||||
<p>
|
||||
<i class="pf-icon pf-icon-error-circle-o pf-u-font-size-2xl" role="img" aria-label="{% trans 'Error' %}"></i>
|
||||
<i class="pf-icon pf-icon-error-circle-o"></i>
|
||||
{% trans 'Request has been denied.' %}
|
||||
</p>
|
||||
{% if error %}
|
||||
@@ -70,11 +71,5 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="pf-c-form__group">
|
||||
<a id="ak-back-home" href="{% url 'authentik_core:root-redirect' %}" class="pf-c-button pf-m-primary pf-m-block">
|
||||
{% trans 'Go home' %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -386,11 +386,18 @@ class OAuth2Provider(WebfingerProvider, Provider):
|
||||
def __str__(self):
|
||||
return f"OAuth2 Provider {self.name}"
|
||||
|
||||
def encode(self, payload: dict[str, Any]) -> str:
|
||||
"""Represent the ID Token as a JSON Web Token (JWT)."""
|
||||
def encode(self, payload: dict[str, Any], jwt_type: str | None = None) -> str:
|
||||
"""Represent the ID Token as a JSON Web Token (JWT).
|
||||
|
||||
:param payload The payload to encode into the JWT
|
||||
:param jwt_type The type of the JWT. This will be put in the JWT header using the `typ`
|
||||
parameter. See RFC7515 Section 4.1.9. If not set fallback to the default of `JWT`.
|
||||
"""
|
||||
headers = {}
|
||||
if self.signing_key:
|
||||
headers["kid"] = self.signing_key.kid
|
||||
if jwt_type is not None:
|
||||
headers["typ"] = jwt_type
|
||||
key, alg = self.jwt_key
|
||||
encoded = encode(payload, key, algorithm=alg, headers=headers)
|
||||
if self.encryption_key:
|
||||
|
||||
@@ -109,7 +109,7 @@ def user_session_deleted_oauth_backchannel_logout_and_tokens_removal(
|
||||
"""Revoke tokens upon user logout"""
|
||||
LOGGER.debug("Sending back-channel logout notifications signal!", session=instance)
|
||||
|
||||
access_tokens = AccessToken.objects.filter(
|
||||
access_tokens = AccessToken.objects.select_related("provider").filter(
|
||||
user=instance.user,
|
||||
session__session__session_key=instance.session.session_key,
|
||||
)
|
||||
@@ -128,7 +128,8 @@ def user_session_deleted_oauth_backchannel_logout_and_tokens_removal(
|
||||
and token.provider.logout_method == OAuth2LogoutMethod.BACKCHANNEL
|
||||
]
|
||||
|
||||
backchannel_logout_notification_dispatch.send(revocations=backchannel_tokens)
|
||||
if backchannel_tokens:
|
||||
backchannel_logout_notification_dispatch.send(revocations=backchannel_tokens)
|
||||
|
||||
access_tokens.delete()
|
||||
|
||||
|
||||
@@ -113,11 +113,16 @@ class TestBackChannelLogout(OAuthTestCase):
|
||||
|
||||
def _decode_token(self, token, provider=None):
|
||||
"""Helper to decode and validate a JWT token"""
|
||||
decoded = self._decode_token_complete(token, provider)
|
||||
return decoded["payload"]
|
||||
|
||||
def _decode_token_complete(self, token, provider=None):
|
||||
"""Helper to decode and validate a JWT token into a header, and payload dict"""
|
||||
provider = provider or self.provider
|
||||
key, alg = provider.jwt_key
|
||||
if alg != "HS256":
|
||||
key = provider.signing_key.public_key
|
||||
return jwt.decode(
|
||||
return jwt.decode_complete(
|
||||
token, key, algorithms=[alg], options={"verify_exp": False, "verify_aud": False}
|
||||
)
|
||||
|
||||
@@ -155,6 +160,16 @@ class TestBackChannelLogout(OAuthTestCase):
|
||||
self.assertEqual(decoded3["sub"], sub)
|
||||
self.assertIn("events", decoded3)
|
||||
|
||||
def test_create_logout_token_header_type(self):
|
||||
"""Test creating logout tokens and checking if the token header type is correct"""
|
||||
session_id = "test-session-123"
|
||||
token1 = self._create_logout_token(session_id=session_id)
|
||||
|
||||
decoded = self._decode_token_complete(token1)
|
||||
|
||||
self.assertIsNotNone(decoded["header"])
|
||||
self.assertEqual(decoded["header"]["typ"], "logout+jwt")
|
||||
|
||||
@patch("authentik.providers.oauth2.tasks.get_http_session")
|
||||
def test_send_backchannel_logout_request_scenarios(self, mock_get_session):
|
||||
"""Test various scenarios for backchannel logout request task"""
|
||||
|
||||
@@ -126,6 +126,30 @@ class TestTokenClientCredentialsUserNamePassword(OAuthTestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_deactivate(self):
|
||||
"""test deactivated user"""
|
||||
self.user.is_active = False
|
||||
self.user.save()
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": SCOPE_OPENID,
|
||||
"client_id": self.provider.client_id,
|
||||
"username": "sa",
|
||||
"password": self.token.key,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(),
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": TokenError.errors["invalid_grant"],
|
||||
"request_id": response.headers["X-authentik-id"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_permission_denied(self):
|
||||
"""test permission denied"""
|
||||
group = Group.objects.create(name="foo")
|
||||
|
||||
@@ -5,6 +5,7 @@ import uuid
|
||||
from base64 import b64decode, urlsafe_b64encode
|
||||
from binascii import Error
|
||||
from hashlib import sha256
|
||||
from hmac import compare_digest
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -206,7 +207,9 @@ def authenticate_provider(request: HttpRequest) -> OAuth2Provider | None:
|
||||
provider, client_id, client_secret = provider_from_request(request)
|
||||
if not provider:
|
||||
return None
|
||||
if client_id != provider.client_id or client_secret != provider.client_secret:
|
||||
if not compare_digest(client_id, provider.client_id) or not compare_digest(
|
||||
client_secret, provider.client_secret
|
||||
):
|
||||
LOGGER.debug("(basic) Provider for basic auth does not exist")
|
||||
return None
|
||||
CTX_AUTH_VIA.set("oauth_client_secret")
|
||||
@@ -259,4 +262,4 @@ def create_logout_token(
|
||||
if session_key:
|
||||
payload["sid"] = hash_session_key(session_key)
|
||||
# Encode the token
|
||||
return provider.encode(payload)
|
||||
return provider.encode(payload, jwt_type="logout+jwt")
|
||||
|
||||
@@ -4,6 +4,7 @@ from base64 import b64decode
|
||||
from binascii import Error
|
||||
from dataclasses import InitVar, dataclass
|
||||
from datetime import datetime
|
||||
from hmac import compare_digest
|
||||
from re import error as RegexError
|
||||
from re import fullmatch
|
||||
from typing import Any
|
||||
@@ -161,9 +162,8 @@ class TokenParams:
|
||||
|
||||
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
|
||||
if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]:
|
||||
if (
|
||||
self.provider.client_type == ClientTypes.CONFIDENTIAL
|
||||
and self.provider.client_secret != self.client_secret
|
||||
if self.provider.client_type == ClientTypes.CONFIDENTIAL and not compare_digest(
|
||||
self.provider.client_secret, self.client_secret
|
||||
):
|
||||
LOGGER.warning(
|
||||
"Invalid client secret",
|
||||
@@ -336,7 +336,7 @@ class TokenParams:
|
||||
self, request: HttpRequest, username: str, password: str
|
||||
):
|
||||
# Authenticate user based on credentials
|
||||
user = User.objects.filter(username=username).first()
|
||||
user = User.objects.filter(username=username, is_active=True).first()
|
||||
if not user:
|
||||
raise TokenError("invalid_grant")
|
||||
token: Token = Token.filter_not_expired(
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
|
||||
{% block body %}
|
||||
<ak-rac token="{{ url_kwargs.token }}" endpointName="{{ token.endpoint.name }}">
|
||||
{% include "base/placeholder.html" %}
|
||||
<ak-loading></ak-loading>
|
||||
</ak-rac>
|
||||
{% endblock %}
|
||||
|
||||
@@ -75,6 +75,8 @@ class TestEndpointsAPI(APITestCase):
|
||||
"component": "ak-provider-rac-form",
|
||||
"assigned_application_slug": self.app.slug,
|
||||
"assigned_application_name": self.app.name,
|
||||
"assigned_backchannel_application_name": None,
|
||||
"assigned_backchannel_application_slug": None,
|
||||
"verbose_name": "RAC Provider",
|
||||
"verbose_name_plural": "RAC Providers",
|
||||
"meta_model_name": "authentik_providers_rac.racprovider",
|
||||
@@ -126,6 +128,8 @@ class TestEndpointsAPI(APITestCase):
|
||||
"component": "ak-provider-rac-form",
|
||||
"assigned_application_slug": self.app.slug,
|
||||
"assigned_application_name": self.app.name,
|
||||
"assigned_backchannel_application_name": None,
|
||||
"assigned_backchannel_application_slug": None,
|
||||
"connection_expiry": "hours=8",
|
||||
"delete_token_on_disconnect": False,
|
||||
"verbose_name": "RAC Provider",
|
||||
@@ -155,6 +159,8 @@ class TestEndpointsAPI(APITestCase):
|
||||
"component": "ak-provider-rac-form",
|
||||
"assigned_application_slug": self.app.slug,
|
||||
"assigned_application_name": self.app.name,
|
||||
"assigned_backchannel_application_name": None,
|
||||
"assigned_backchannel_application_slug": None,
|
||||
"connection_expiry": "hours=8",
|
||||
"delete_token_on_disconnect": False,
|
||||
"verbose_name": "RAC Provider",
|
||||
|
||||
@@ -61,14 +61,6 @@ class SAMLProvider(Provider):
|
||||
acs_url = models.TextField(
|
||||
validators=[DomainlessURLValidator(schemes=("http", "https"))], verbose_name=_("ACS URL")
|
||||
)
|
||||
sp_binding = models.TextField(
|
||||
choices=SAMLBindings.choices,
|
||||
default=SAMLBindings.REDIRECT,
|
||||
verbose_name=_("Service Provider Binding"),
|
||||
help_text=_(
|
||||
"This determines how authentik sends the response back to the Service Provider."
|
||||
),
|
||||
)
|
||||
audience = models.TextField(
|
||||
default="",
|
||||
blank=True,
|
||||
@@ -78,6 +70,14 @@ class SAMLProvider(Provider):
|
||||
),
|
||||
)
|
||||
issuer = models.TextField(help_text=_("Also known as EntityID"), default="authentik")
|
||||
sp_binding = models.TextField(
|
||||
choices=SAMLBindings.choices,
|
||||
default=SAMLBindings.REDIRECT,
|
||||
verbose_name=_("Service Provider Binding"),
|
||||
help_text=_(
|
||||
"This determines how authentik sends the response back to the Service Provider."
|
||||
),
|
||||
)
|
||||
sls_url = models.TextField(
|
||||
blank=True,
|
||||
validators=[DomainlessURLValidator(schemes=("http", "https"))],
|
||||
|
||||
@@ -9,10 +9,9 @@ from defusedxml.lxml import fromstring
|
||||
from lxml import etree # nosec
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.crypto.models import CertificateKeyPair, format_cert
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.utils.encoding import PEM_FOOTER, PEM_HEADER
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
NS_MAP,
|
||||
@@ -24,18 +23,6 @@ from authentik.sources.saml.processors.constants import (
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def format_pem_certificate(unformatted_cert: str) -> str:
|
||||
"""Format single, inline certificate into PEM Format"""
|
||||
# Ensure that all linebreaks are gone
|
||||
unformatted_cert = unformatted_cert.replace("\n", "")
|
||||
chunks, chunk_size = len(unformatted_cert), 64
|
||||
lines = [PEM_HEADER]
|
||||
for i in range(0, chunks, chunk_size):
|
||||
lines.append(unformatted_cert[i : i + chunk_size])
|
||||
lines.append(PEM_FOOTER)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ServiceProviderMetadata:
|
||||
"""SP Metadata Dataclass"""
|
||||
@@ -87,7 +74,7 @@ class ServiceProviderMetadataParser:
|
||||
)
|
||||
if len(signing_certs) < 1:
|
||||
return None
|
||||
raw_cert = format_pem_certificate(signing_certs[0])
|
||||
raw_cert = format_cert(signing_certs[0])
|
||||
# sanity check, make sure the certificate is valid.
|
||||
load_pem_x509_certificate(raw_cert.encode("utf-8"), default_backend())
|
||||
return CertificateKeyPair(
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import base64
|
||||
import zlib
|
||||
|
||||
PEM_HEADER = "-----BEGIN CERTIFICATE-----"
|
||||
PEM_FOOTER = "-----END CERTIFICATE-----"
|
||||
from ssl import PEM_FOOTER, PEM_HEADER
|
||||
|
||||
|
||||
def decode_base64_and_inflate(encoded: str, encoding="utf-8") -> str:
|
||||
|
||||
@@ -38,8 +38,6 @@ class SCIMProviderSerializer(
|
||||
"compatibility_mode",
|
||||
"exclude_users_service_account",
|
||||
"filter_group",
|
||||
"sync_page_size",
|
||||
"sync_page_timeout",
|
||||
"dry_run",
|
||||
]
|
||||
extra_kwargs = {}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""Group client"""
|
||||
|
||||
from itertools import batched
|
||||
from typing import Any
|
||||
|
||||
from django.db import transaction
|
||||
from orjson import dumps
|
||||
from pydantic import ValidationError
|
||||
from pydanticscim.group import GroupMember
|
||||
|
||||
from authentik.core.models import Group
|
||||
from authentik.lib.merge import MERGE_LIST_UNIQUE
|
||||
from authentik.lib.sync.mapper import PropertyMappingManager
|
||||
from authentik.lib.sync.outgoing.base import Direction
|
||||
from authentik.lib.sync.outgoing.exceptions import (
|
||||
@@ -113,10 +116,23 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
||||
self._patch_add_users(connection, users)
|
||||
return connection
|
||||
|
||||
def diff(self, local_created: dict[str, Any], connection: SCIMProviderUser):
|
||||
"""Check if a group is different than what we last wrote to the remote system.
|
||||
Returns true if there is a difference in data."""
|
||||
local_known = connection.attributes
|
||||
local_updated = {}
|
||||
MERGE_LIST_UNIQUE.merge(local_updated, local_known)
|
||||
MERGE_LIST_UNIQUE.merge(local_updated, local_created)
|
||||
return dumps(local_updated) != dumps(local_known)
|
||||
|
||||
def update(self, group: Group, connection: SCIMProviderGroup):
|
||||
"""Update existing group"""
|
||||
scim_group = self.to_schema(group, connection)
|
||||
scim_group.id = connection.scim_id
|
||||
payload = scim_group.model_dump(mode="json", exclude_unset=True)
|
||||
if not self.diff(payload, connection):
|
||||
self.logger.debug("Skipping group write as data has not changed")
|
||||
return self.patch_compare_users(group)
|
||||
try:
|
||||
if self._config.patch.supported:
|
||||
return self._update_patch(group, scim_group, connection)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""User client"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils.http import urlencode
|
||||
from orjson import dumps
|
||||
from pydantic import ValidationError
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.lib.merge import MERGE_LIST_UNIQUE
|
||||
from authentik.lib.sync.mapper import PropertyMappingManager
|
||||
from authentik.lib.sync.outgoing.exceptions import ObjectExistsSyncException, StopSync
|
||||
from authentik.policies.utils import delete_none_values
|
||||
@@ -92,17 +96,30 @@ class SCIMUserClient(SCIMClient[User, SCIMProviderUser, SCIMUserSchema]):
|
||||
provider=self.provider, user=user, scim_id=scim_id, attributes=response
|
||||
)
|
||||
|
||||
def diff(self, local_created: dict[str, Any], connection: SCIMProviderUser):
|
||||
"""Check if a user is different than what we last wrote to the remote system.
|
||||
Returns true if there is a difference in data."""
|
||||
local_known = connection.attributes
|
||||
local_updated = {}
|
||||
MERGE_LIST_UNIQUE.merge(local_updated, local_known)
|
||||
MERGE_LIST_UNIQUE.merge(local_updated, local_created)
|
||||
return dumps(local_updated) != dumps(local_known)
|
||||
|
||||
def update(self, user: User, connection: SCIMProviderUser):
|
||||
"""Update existing user"""
|
||||
scim_user = self.to_schema(user, connection)
|
||||
scim_user.id = connection.scim_id
|
||||
payload = scim_user.model_dump(
|
||||
mode="json",
|
||||
exclude_unset=True,
|
||||
)
|
||||
if not self.diff(payload, connection):
|
||||
self.logger.debug("Skipping user write as data has not changed")
|
||||
return
|
||||
response = self._request(
|
||||
"PUT",
|
||||
f"/Users/{connection.scim_id}",
|
||||
json=scim_user.model_dump(
|
||||
mode="json",
|
||||
exclude_unset=True,
|
||||
),
|
||||
json=payload,
|
||||
)
|
||||
connection.attributes = response
|
||||
connection.save()
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-21 12:35
|
||||
|
||||
import authentik.lib.utils.time
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_scim", "0015_alter_scimprovider_compatibility_mode"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="scimprovider",
|
||||
name="sync_page_size",
|
||||
field=models.PositiveIntegerField(
|
||||
default=100,
|
||||
help_text="Controls the number of objects synced in a single task",
|
||||
validators=[django.core.validators.MinValueValidator(1)],
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="scimprovider",
|
||||
name="sync_page_timeout",
|
||||
field=models.TextField(
|
||||
default="minutes=30",
|
||||
help_text="Timeout for synchronization of a single page",
|
||||
validators=[authentik.lib.utils.time.timedelta_string_validator],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -9,7 +9,7 @@ from requests_mock import Mocker
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application, Group, User
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider, SCIMProviderGroup
|
||||
|
||||
|
||||
class SCIMGroupTests(TestCase):
|
||||
@@ -106,6 +106,7 @@ class SCIMGroupTests(TestCase):
|
||||
"displayName": group.name,
|
||||
},
|
||||
)
|
||||
group.name = generate_id()
|
||||
group.save()
|
||||
self.assertEqual(mock.call_count, 4)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
@@ -148,3 +149,56 @@ class SCIMGroupTests(TestCase):
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[3].method, "DELETE")
|
||||
self.assertEqual(mock.request_history[3].url, f"https://localhost/Groups/{scim_id}")
|
||||
|
||||
@Mocker()
|
||||
def test_group_create_update_noop(self, mock: Mocker):
|
||||
"""Test group creation and update"""
|
||||
scim_id = generate_id()
|
||||
mock.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json={},
|
||||
)
|
||||
mock.post(
|
||||
"https://localhost/Groups",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
mock.put(
|
||||
"https://localhost/Groups",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
uid = generate_id()
|
||||
group = Group.objects.create(
|
||||
name=uid,
|
||||
)
|
||||
self.assertEqual(mock.call_count, 2)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].method, "POST")
|
||||
body = loads(mock.request_history[1].body)
|
||||
with open("schemas/scim-group.schema.json", encoding="utf-8") as schema:
|
||||
validate(body, loads(schema.read()))
|
||||
self.assertEqual(
|
||||
body,
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
"externalId": str(group.pk),
|
||||
"displayName": group.name,
|
||||
},
|
||||
)
|
||||
conn = SCIMProviderGroup.objects.filter(group=group).first()
|
||||
conn.attributes = {
|
||||
"id": scim_id,
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
"externalId": str(group.pk),
|
||||
"displayName": group.name,
|
||||
}
|
||||
conn.save()
|
||||
group.save()
|
||||
self.assertEqual(mock.call_count, 4)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].method, "POST")
|
||||
self.assertEqual(mock.request_history[2].method, "GET")
|
||||
self.assertEqual(mock.request_history[2].method, "GET")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user