├── .editorconfig ├── .github └── workflows │ ├── main.yml │ ├── payload-slack-content.json │ ├── payload-slack-deploy.json │ └── pull_requests_only.yml ├── .gitignore ├── .keep ├── .pre-commit-config.yaml ├── .stylelintrc ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── README.md ├── ci └── node-install.sh ├── clouddeploy.yaml ├── clouddeploy ├── skaffold.template.yaml ├── sso-dashboard-dev.template.yaml ├── sso-dashboard-prod.template.yaml └── sso-dashboard-staging.template.yaml ├── compose.yml ├── dashboard ├── __init__.py ├── app.py ├── config.py ├── csp.py ├── data │ └── .keep ├── logging.ini ├── models │ ├── __init__.py │ ├── apps.py │ ├── tile.py │ └── user.py ├── oidc_auth.py ├── op │ ├── __init__.py │ └── yaml_loader.py ├── static │ ├── css │ │ ├── base.scss │ │ └── nlx.css │ ├── fonts │ │ ├── opensans-bold.woff │ │ ├── opensans-bold.woff2 │ │ ├── opensans-bolditalic.woff │ │ ├── opensans-bolditalic.woff2 │ │ ├── opensans-italic.woff │ │ ├── opensans-italic.woff2 │ │ ├── opensans-regular.woff │ │ └── opensans-regular.woff2 │ ├── img │ │ ├── 404-birdcage.jpg │ │ ├── auth0.png │ │ ├── email.svg │ │ ├── favicon.ico │ │ ├── feedback.svg │ │ ├── github.svg │ │ ├── legal.svg │ │ ├── logout.svg │ │ ├── mozilla-m.svg │ │ ├── mozilla.svg │ │ ├── privacy.svg │ │ ├── request.svg │ │ ├── search-w.svg │ │ ├── search.svg │ │ ├── settings.svg │ │ └── success.svg │ ├── js │ │ ├── base.js │ │ └── ga.js │ └── lib │ │ ├── dnt-helper │ │ └── js │ │ │ └── dnt-helper.js │ │ ├── jquery │ │ └── dist │ │ │ └── jquery.min.js │ │ └── muicss │ │ └── dist │ │ ├── css │ │ └── mui.min.css │ │ └── js │ │ └── mui.min.js ├── templates │ ├── 404.html │ ├── about.html │ ├── dashboard.html │ ├── forbidden.html │ ├── home.html │ ├── icons │ │ ├── arrow-right.svg │ │ ├── info.svg │ │ ├── loading-black.svg │ │ └── loading-white.svg │ ├── layout.html │ └── signout.html └── vanity.py ├── docs ├── architecture.mermaid ├── development.md └── images │ ├── architecture.png │ └── dashboard.png ├── env.example ├── eslint.config.mjs ├── package-lock.json ├── package.json ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── data │ ├── apps.yml │ ├── mfa-required-jwt │ ├── public-signing-key.pem │ └── userinfo.json ├── models │ ├── __init__.py │ ├── test_tile.py │ └── test_user.py ├── op │ ├── __init__.py │ └── test_yaml_loader.py ├── sso-dashboard.ini ├── test_oidc_auth.py └── test_vanity.py ├── tools └── copy-static-files.js └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # General configuration 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | 10 | # Python, Javscript and css configuration 11 | [*.{py,js,css,scss}] 12 | indent_size = 4 13 | 14 | # Html and yaml configuration 15 | [*.{html,yml}] 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy SSO Dashboard 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - 'master' 8 | 9 | permissions: 10 | contents: 'read' 11 | id-token: 'write' 12 | 13 | env: 14 | APP: sso-dashboard 15 | GAR_LOCATION: us-east1 16 | PROJECT_ID: iam-auth0 17 | REGION: us-east1 18 | CHANNEL_IDS: C05AMLCL4JX 19 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN}} 20 | 21 | jobs: 22 | init: 23 | name: Init 24 | runs-on: ubuntu-latest 25 | outputs: 26 | release_name: ${{ steps.release_name.outputs.release_name }} 27 | docker_tag: ${{ steps.docker_tag.outputs.docker_tag }} 28 | slack_ts: ${{ steps.slack_ts.outputs.slack_ts }} 29 | steps: 30 | - name: 'Checkout' 31 | uses: 'actions/checkout@v4' 32 | 33 | - name: 'Create release name' 34 | id: release_name 35 | run: echo "RELEASE_NAME=${{ env.APP }}-${GITHUB_SHA::7}-${GITHUB_RUN_NUMBER}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" 36 | 37 | - name: 'Create docker tag' 38 | id: docker_tag 39 | run: echo "DOCKER_TAG=${{ env.GAR_LOCATION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.APP }}/${{ env.APP }}:${{ github.sha }}" >> "$GITHUB_OUTPUT" 40 | 41 | - name: Send initial slack notification 42 | uses: slackapi/slack-github-action@v1.25.0 43 | id: slack 44 | with: 45 | channel-id: ${{ env.CHANNEL_IDS }} 46 | payload-file-path: ".github/workflows/payload-slack-content.json" 47 | env: 48 | STATUS_COLOR: dbab09 49 | STATUS_TITLE: Starting Deployment Pipeline 50 | STATUS_VALUE: ':link-run: *Running*' 51 | 52 | - name: Output slack ts 53 | id: slack_ts 54 | run: echo "SLACK_TS=${{ steps.slack.outputs.ts }}" >> "$GITHUB_OUTPUT" 55 | 56 | lint: 57 | name: Linting / Unit Testing 58 | needs: init 59 | runs-on: ubuntu-latest 60 | env: 61 | RELEASE_NAME: ${{needs.init.outputs.release_name}} 62 | steps: 63 | - name: 'Checkout' 64 | uses: 'actions/checkout@v4' 65 | 66 | - name: Update slack notification 67 | uses: slackapi/slack-github-action@v1.25.0 68 | with: 69 | update-ts: ${{ needs.init.outputs.slack_ts }} 70 | channel-id: ${{ env.CHANNEL_IDS }} 71 | payload-file-path: ".github/workflows/payload-slack-content.json" 72 | env: 73 | STATUS_COLOR: dbab09 74 | STATUS_TITLE: Linting/Unittesting 75 | STATUS_VALUE: ':link-run: *Running*' 76 | 77 | - name: Set up Python 78 | uses: actions/setup-python@v4 79 | with: 80 | python-version: '3.12' 81 | 82 | - name: Install dependencies 83 | run: | 84 | python -m pip install --upgrade pip 85 | pip install pre-commit tox 86 | 87 | - name: Run pre-commit 88 | run: pre-commit run --all-files 89 | 90 | build: 91 | name: Building 92 | needs: [ init, lint ] 93 | runs-on: ubuntu-latest 94 | environment: production 95 | env: 96 | RELEASE_NAME: ${{needs.init.outputs.release_name}} 97 | DOCKER_TAG: ${{needs.init.outputs.docker_tag}} 98 | steps: 99 | - name: 'Checkout' 100 | uses: 'actions/checkout@v4' 101 | 102 | - name: Update slack notification 103 | uses: slackapi/slack-github-action@v1.25.0 104 | with: 105 | update-ts: ${{ needs.init.outputs.slack_ts }} 106 | channel-id: ${{ env.CHANNEL_IDS }} 107 | payload-file-path: ".github/workflows/payload-slack-content.json" 108 | env: 109 | STATUS_COLOR: dbab09 110 | STATUS_TITLE: Building Docker Image 111 | STATUS_VALUE: ':link-run: *Running*' 112 | 113 | - name: 'Google auth' 114 | id: 'auth' 115 | uses: 'google-github-actions/auth@v2' 116 | with: 117 | workload_identity_provider: '${{ secrets.WIF_PROVIDER }}' 118 | service_account: '${{ secrets.WIF_SERVICE_ACCOUNT }}' 119 | 120 | - name: 'Docker auth' 121 | run: gcloud auth configure-docker ${{ env.GAR_LOCATION }}-docker.pkg.dev --quiet 122 | 123 | - name: Set up Docker Buildx 124 | uses: docker/setup-buildx-action@v2 125 | 126 | - name: Build and push Docker image with buildx 127 | uses: docker/build-push-action@v4 128 | with: 129 | context: . 130 | push: true 131 | build-args: RELEASE_NAME=${{ env.RELEASE_NAME }} 132 | tags: "${{ env.DOCKER_TAG }}" 133 | cache-from: type=gha 134 | cache-to: type=gha,mode=max 135 | 136 | validate: 137 | name: Validating 138 | needs: [ init, lint, build ] 139 | runs-on: ubuntu-latest 140 | environment: production 141 | env: 142 | RELEASE_NAME: ${{needs.init.outputs.release_name}} 143 | DOCKER_TAG: ${{needs.init.outputs.docker_tag}} 144 | steps: 145 | - name: 'Checkout' 146 | uses: 'actions/checkout@v4' 147 | 148 | - name: Update slack notification 149 | uses: slackapi/slack-github-action@v1.25.0 150 | with: 151 | update-ts: ${{ needs.init.outputs.slack_ts }} 152 | channel-id: ${{ env.CHANNEL_IDS }} 153 | payload-file-path: ".github/workflows/payload-slack-content.json" 154 | env: 155 | STATUS_COLOR: dbab09 156 | STATUS_TITLE: Validating Image 157 | STATUS_VALUE: ':link-run: *Running*' 158 | 159 | - name: 'Google auth' 160 | id: 'auth' 161 | uses: 'google-github-actions/auth@v2' 162 | with: 163 | workload_identity_provider: '${{ secrets.WIF_PROVIDER }}' 164 | service_account: '${{ secrets.WIF_SERVICE_ACCOUNT }}' 165 | 166 | - name: 'Docker auth' 167 | run: gcloud auth configure-docker ${{ env.GAR_LOCATION }}-docker.pkg.dev 168 | 169 | - name: Pull Docker image 170 | run: docker pull "${{ env.DOCKER_TAG }}" 171 | 172 | - name: Run validate docker image 173 | uses: addnab/docker-run-action@v3 174 | with: 175 | image: "${{ env.DOCKER_TAG }}" 176 | run: echo "TODO Add docker validation checks" 177 | 178 | deploy: 179 | name: Sending to Cloud Deploy 180 | needs: [ init, lint, build, validate ] 181 | runs-on: ubuntu-latest 182 | environment: production 183 | env: 184 | RELEASE_NAME: ${{needs.init.outputs.release_name}} 185 | DOCKER_TAG: ${{needs.init.outputs.docker_tag}} 186 | steps: 187 | - name: 'Checkout' 188 | uses: 'actions/checkout@v4.1.1' 189 | 190 | - name: Update slack notification 191 | uses: slackapi/slack-github-action@v1.25.0 192 | with: 193 | update-ts: ${{ needs.init.outputs.slack_ts }} 194 | channel-id: ${{ env.CHANNEL_IDS }} 195 | payload-file-path: ".github/workflows/payload-slack-content.json" 196 | env: 197 | STATUS_COLOR: dbab09 198 | STATUS_TITLE: Sending to Cloud Deploy 199 | STATUS_VALUE: ':link-run: *Running*' 200 | 201 | - name: 'Google auth' 202 | id: 'auth' 203 | uses: 'google-github-actions/auth@v2' 204 | with: 205 | workload_identity_provider: '${{ secrets.WIF_PROVIDER }}' 206 | service_account: '${{ secrets.WIF_SERVICE_ACCOUNT }}' 207 | 208 | - name: 'Render cloud deploy config manifests from templates' 209 | run: for template in $(ls clouddeploy/*.template.yaml); do envsubst < ${template} > ${template%%.*}.yaml ; done 210 | 211 | - name: 'Create Cloud Deploy release' 212 | uses: 'google-github-actions/create-cloud-deploy-release@v0' 213 | with: 214 | delivery_pipeline: '${{ env.APP }}' 215 | name: '${{ env.RELEASE_NAME }}' 216 | region: '${{ env.REGION }}' 217 | description: '${{ env.GITHUB_COMMIT_MSG }}' 218 | skaffold_file: 'clouddeploy/skaffold.yaml' 219 | images: 'app=${{ env.DOCKER_TAG }}' 220 | 221 | final: 222 | name: Finalize Notifications 223 | needs: [ init, lint, build, validate, deploy ] 224 | runs-on: ubuntu-latest 225 | if: always() 226 | env: 227 | RELEASE_NAME: ${{needs.init.outputs.release_name}} 228 | steps: 229 | - name: 'Checkout' 230 | uses: 'actions/checkout@v4' 231 | 232 | - name: Update slack deployment complete 233 | if: needs.deploy.result == 'success' 234 | uses: slackapi/slack-github-action@v1.25.0 235 | with: 236 | update-ts: ${{ needs.init.outputs.slack_ts }} 237 | channel-id: ${{ env.CHANNEL_IDS }} 238 | payload-file-path: ".github/workflows/payload-slack-content.json" 239 | env: 240 | STATUS_COLOR: 28a745 241 | STATUS_TITLE: Building and Deploy 242 | STATUS_VALUE: ':link-zelda: *Completed*' 243 | 244 | - name: Update slack deployment ready for promotion 245 | if: needs.deploy.result == 'success' 246 | uses: slackapi/slack-github-action@v1.25.0 247 | with: 248 | channel-id: ${{ env.CHANNEL_IDS }} 249 | payload-file-path: ".github/workflows/payload-slack-deploy.json" 250 | 251 | - name: Update slack deployment failed 252 | if: needs.lint.result == 'failure' || needs.build.result == 'failure' || needs.validate.result == 'failure' || needs.deploy.result == 'failure' 253 | uses: slackapi/slack-github-action@v1.25.0 254 | with: 255 | update-ts: ${{ needs.init.outputs.slack_ts }} 256 | channel-id: ${{ env.CHANNEL_IDS }} 257 | payload-file-path: ".github/workflows/payload-slack-content.json" 258 | env: 259 | STATUS_COLOR: d81313 260 | STATUS_TITLE: Building and Deploy 261 | STATUS_VALUE: ':skull_and_crossbones: *Failed*' 262 | -------------------------------------------------------------------------------- /.github/workflows/payload-slack-content.json: -------------------------------------------------------------------------------- 1 | { 2 | "text": "", 3 | "attachments": [ 4 | { 5 | "color": "{{ env.STATUS_COLOR }}", 6 | "blocks": [ 7 | { 8 | "type": "header", 9 | "text": { 10 | "type": "plain_text", 11 | "text": ":link-wut: Github Action Notification :link-wut:\n{{ github.workflow }}", 12 | "emoji": true 13 | } 14 | }, 15 | { 16 | "type": "section", 17 | "fields": [ 18 | { 19 | "type": "plain_text", 20 | "text": "{{ env.RELEASE_NAME }}", 21 | "emoji": true 22 | }, 23 | { 24 | "type": "plain_text", 25 | "text": "{{ env.GITHUB_ACTOR }}", 26 | "emoji": true 27 | }, 28 | { 29 | "type": "plain_text", 30 | "text": "{{ env.GITHUB_REPOSITORY }}", 31 | "emoji": true 32 | }, 33 | { 34 | "type": "plain_text", 35 | "text": "{{ env.GITHUB_REF_NAME }}", 36 | "emoji": true 37 | } 38 | ] 39 | }, 40 | { 41 | "type": "section", 42 | "text": { 43 | "type": "mrkdwn", 44 | "text": "" 45 | } 46 | }, 47 | { 48 | "type": "context", 49 | "elements": [ 50 | { 51 | "type": "mrkdwn", 52 | "text": "Action: *{{ env.STATUS_TITLE }}*" 53 | }, 54 | { 55 | "type": "mrkdwn", 56 | "text": "Status: {{ env.STATUS_VALUE }}" 57 | } 58 | ] 59 | } 60 | ] 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /.github/workflows/payload-slack-deploy.json: -------------------------------------------------------------------------------- 1 | { 2 | "text": "", 3 | "attachments": [ 4 | { 5 | "color": "28a745", 6 | "blocks": [ 7 | { 8 | "type": "header", 9 | "text": { 10 | "type": "plain_text", 11 | "text": ":rocket: SSO Dashboard is ready for Promotion", 12 | "emoji": true 13 | } 14 | }, 15 | { 16 | "type": "section", 17 | "text": { 18 | "type": "mrkdwn", 19 | "text": "Build: *{{ env.RELEASE_NAME }}*" 20 | } 21 | }, 22 | { 23 | "type": "section", 24 | "text": { 25 | "type": "mrkdwn", 26 | "text": ":link: " 27 | } 28 | } 29 | ] 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/pull_requests_only.yml: -------------------------------------------------------------------------------- 1 | name: PR Linting / Unit Testing 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - '*' 7 | 8 | permissions: 9 | contents: 'read' 10 | 11 | jobs: 12 | lint: 13 | name: Linting / Unit Testing 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: 'Checkout' 17 | uses: 'actions/checkout@v4' 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: '3.12' 23 | 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install pre-commit tox 28 | 29 | - name: Run pre-commit 30 | run: pre-commit run --all-files 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | .venv 3 | dashboard/data/apps.yml-etag 4 | dashboard/data/apps.yml 5 | dump.rdb 6 | .env 7 | env3 8 | env 9 | dashboard/static/js/gen/ 10 | dashboard/static/css/gen/ 11 | dashboard/static/.webassets-cache/ 12 | dashboard/static/img/logos/* 13 | virtualenv.egg-info 14 | dashboard.egg-info 15 | bin 16 | man 17 | include 18 | docs/_build 19 | pip-selfcheck.json 20 | .python 21 | .DS_Store 22 | *.py[cod] 23 | *.egg 24 | .eggs 25 | .tox 26 | .cache 27 | tests/test_activate_actual.output 28 | bower_components 29 | node_modules 30 | .vscode/* 31 | build/* 32 | envfile 33 | .coverage 34 | -------------------------------------------------------------------------------- /.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/.keep -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: trailing-whitespace 8 | exclude: | 9 | (?x)^(dashboard/static/lib.*)$ 10 | - id: end-of-file-fixer 11 | exclude: | 12 | (?x)^(dashboard/static/lib.*)$ 13 | - id: check-yaml 14 | args: [--allow-multiple-documents] 15 | - id: check-added-large-files 16 | 17 | - repo: local 18 | hooks: 19 | - id: run-tox 20 | name: Run tox tests 21 | entry: tox 22 | language: system 23 | pass_filenames: false 24 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard-scss", 3 | "rules": { 4 | "no-descending-specificity": null, 5 | "color-no-invalid-hex": true, 6 | 7 | "font-family-no-duplicate-names": true, 8 | "font-family-name-quotes": "always-where-recommended", 9 | 10 | "function-calc-no-unspaced-operator": true, 11 | "function-name-case": "lower", 12 | "function-url-no-scheme-relative": true, 13 | "function-url-quotes": "always", 14 | 15 | "string-no-newline": true, 16 | 17 | "length-zero-no-unit": true, 18 | 19 | "unit-no-unknown": true, 20 | 21 | "value-keyword-case": "lower", 22 | 23 | "property-no-unknown": true, 24 | 25 | "keyframe-declaration-no-important": true, 26 | 27 | "declaration-no-important": true, 28 | 29 | "declaration-block-single-line-max-declarations": 1, 30 | "declaration-block-no-shorthand-property-overrides": true, 31 | "declaration-block-no-duplicate-properties": true, 32 | 33 | "block-no-empty": true, 34 | 35 | "selector-pseudo-class-no-unknown": true, 36 | "selector-pseudo-element-no-unknown": true, 37 | "selector-type-case": "lower", 38 | "selector-type-no-unknown": true, 39 | 40 | "rule-empty-line-before": "always-multi-line", 41 | 42 | "media-feature-name-no-unknown": true, 43 | 44 | "comment-no-empty": true, 45 | 46 | "max-nesting-depth": 6, 47 | "no-duplicate-selectors": true, 48 | "no-unknown-animations": true, 49 | "no-invalid-double-slash-comments": true 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Community Participation Guidelines 2 | 3 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. 4 | For more details, please read the 5 | [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). 6 | 7 | ## How to Report 8 | For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page. 9 | 10 | 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-bullseye 2 | ARG RELEASE_NAME 3 | 4 | # Install Node.js 18.20.4 and global npm packages in a single layer 5 | RUN apt update && apt install -y curl \ 6 | && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ 7 | && apt-get install -y nodejs=18.20.4-1nodesource1 \ 8 | && npm install -g sass \ 9 | && rm -rf /var/lib/apt/lists/* 10 | 11 | # Upgrade pip, install Python dependencies, and clean up in a single layer 12 | COPY ./requirements.txt /dashboard/ 13 | RUN pip3 install --upgrade pip \ 14 | && pip3 install -r /dashboard/requirements.txt 15 | 16 | # Copy the dashboard code and create necessary directories and files 17 | COPY ./dashboard/ /dashboard/ 18 | RUN mkdir -p /dashboard/data /dashboard/static/img/logos \ 19 | && touch /dashboard/data/apps.yml \ 20 | && chmod 750 -R /dashboard \ 21 | && rm /dashboard/static/css/gen/all.css \ 22 | /dashboard/static/js/gen/packed.js \ 23 | /dashboard/data/apps.yml-etag 2>/dev/null || true 24 | 25 | # Write the release name to a version file 26 | RUN echo $RELEASE_NAME > /version.json 27 | 28 | # Set the entrypoint for the container 29 | ENTRYPOINT ["gunicorn", "dashboard.app:app"] 30 | 31 | # This sets the default args which should be overridden 32 | # by cloud deploy. In general, these should match the 33 | # args used in cloud deploy dev environment 34 | # Default command arguments 35 | CMD ["--worker-class=gevent", "--bind=0.0.0.0:8000", "--workers=3", "--graceful-timeout=30", "--timeout=60", "--log-config=dashboard/logging.ini", "--reload", "--reload-extra-file=/dashboard/data/apps.yml"] 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit) 4 | 5 | # Mozilla-IAM Single Sign-On Dashboard 6 | 7 | A Python Flask implementation of an SSO dashboard with OIDC for authentication. 8 | 9 | !['architecture.png'](docs/images/architecture.png) 10 | 11 | > High-Level Architecture Diagram of the Dashboard and related services. Diagram source is available [here](docs/architecture.mermaid). 12 | 13 | !['dashboard.png'](docs/images/dashboard.png) 14 | 15 | > The dashboard prototype as it exists today. This screenshot will be updated as the dashboard UI evolves. 16 | 17 | # Contributors 18 | 19 | * Jake Watkins [:dividehex] jwatkins@mozilla.com 20 | * Andrew Krug [:andrew] akrug@mozilla.com 21 | 22 | # Projects used by this Project 23 | 24 | * Flask 25 | * Redis 26 | * Jinja 27 | * Flask-SSE 28 | * Gunicorn 29 | * MUI-CSS Framework 30 | * Docker 31 | 32 | # Features 33 | 34 | * Control over what apps a user sees 35 | * User profile editor 36 | * Vanity URLs for applications 37 | * Quick search for locating and opening applications 38 | 39 | # Authentication Flow 40 | 41 | All authentications are performed through Auth0 for all users. 42 | 43 | # Authorization Flow 44 | 45 | This app does not technically provide authorization. However, it does check a file using rule syntax to determine what applications should be in the user's dashboard. The rule file exists in _dashboard/data/apps.yml_. 46 | 47 | ## Sample Rule File Syntax 48 | 49 | ``` 50 | --- 51 | apps: 52 | - application: 53 | name: "Demo App 1" 54 | op: auth0 55 | aal: LOW 56 | url: "https://foo.bar.com" 57 | logo: "auth0.png" 58 | authorized_users: [] 59 | authorized_groups: [] 60 | display: false 61 | vanity_url: 62 | - /demo 63 | ``` 64 | 65 | > During authorization, the app checks the user's group membership. If a user is a member of the required groups and they exist in their profile, the user is shown the icon. 66 | 67 | __Note: The display false attribute will cause the app not to be displayed at all under any circumstance. This exists largely to facilitate dev apps or app staging and then taking apps live.__ 68 | 69 | # Adding Apps to the Dashboard 70 | 71 | To add applications to the dashboard, create an application entry in the `apps.yml` file and add a logo under the images directory. 72 | 73 | [https://github.com/mozilla-iam/sso-dashboard-configuration](https://github.com/mozilla-iam/sso-dashboard-configuration) 74 | 75 | # Logos 76 | 77 | These are the rules for the logos. They have to conform to some standards due to the fact they are in a responsive grid. 78 | 79 | 1. Logos should be 120px by 40px (or same aspect). 80 | 2. Logos should be in .png format. 81 | 82 | # Development Guide 83 | 84 | For more information on developing features for the SSO Dashboard, see the [development guide](docs/development.md). 85 | 86 | # Deployment 87 | 88 | This section gives an overview of the SSO Dashboard deployment. For a more detailed explanation, check [this document](https://github.com/mozilla-iam/iam-infra/blob/74a68749db6f9043bdd36970d0e94de322cd9804/docs/runbooks/sso-dashboard.md). 89 | 90 | The Single Sign-On (SSO) Dashboard runs on GCP Cloud Run and is automatically deployed to a Cloud Deploy pipeline via GitHub Actions upon merging to the master branch. Once deployed to Cloud Deploy, it becomes immediately available in the Cloud Run development environment for smoke testing. The IAM team can then promote the build from development to staging for further testing, and subsequently to production when ready. 91 | 92 | Cloud Run environments: 93 | - Production environment can be reached at https://sso.mozilla.com 94 | - Staging environment can be reached at https://staging.sso.mozilla.com 95 | - Development environment can be reached at https://sso.allizom.org 96 | 97 | Each Cloud Run environment's settings are located in the `clouddeploy` directory. 98 | -------------------------------------------------------------------------------- /ci/node-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Meant to be used with tox. Probably don't use this normally. 3 | 4 | set -eu 5 | 6 | # If node isn't installed, install it using nodeenv (along with npm). 7 | if [ ! -f "$TOX_ENV_DIR/bin/node" ] || [ "$("$TOX_ENV_DIR/bin/node" --version)" != "v$NODE_VERSION" ]; then 8 | nodeenv --prebuilt -p --node "$NODE_VERSION" --with-npm --npm "$NPM_VERSION" "$TOX_ENV_DIR" 9 | fi 10 | 11 | # Ensure npm is the version we want. 12 | if [ "$("$TOX_ENV_DIR/bin/npm" --version)" != "$NPM_VERSION" ]; then 13 | npm install -g "npm@$NPM_VERSION" 14 | fi 15 | 16 | npm ci 17 | -------------------------------------------------------------------------------- /clouddeploy.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: deploy.cloud.google.com/v1 2 | kind: DeliveryPipeline 3 | metadata: 4 | name: 'sso-dashboard' 5 | description: 'Deployment pipeline for sso-dashboard' 6 | serialPipeline: 7 | stages: 8 | - targetId: 'dev' 9 | profiles: ['dev'] 10 | - targetId: 'staging' 11 | profiles: ['staging'] 12 | - targetId: 'prod' 13 | profiles: ['prod'] 14 | --- 15 | apiVersion: deploy.cloud.google.com/v1 16 | kind: Target 17 | metadata: 18 | name: 'dev' 19 | description: 'Development target' 20 | run: 21 | location: 'projects/iam-auth0/locations/us-east1' 22 | executionConfigs: 23 | - usages: 24 | - RENDER 25 | - DEPLOY 26 | serviceAccount: sso-dashboard-prod@iam-auth0.iam.gserviceaccount.com 27 | --- 28 | apiVersion: deploy.cloud.google.com/v1 29 | kind: Target 30 | metadata: 31 | name: 'staging' 32 | description: 'Staging target' 33 | run: 34 | location: 'projects/iam-auth0/locations/us-east1' 35 | executionConfigs: 36 | - usages: 37 | - RENDER 38 | - DEPLOY 39 | serviceAccount: sso-dashboard-staging@iam-auth0.iam.gserviceaccount.com 40 | --- 41 | apiVersion: deploy.cloud.google.com/v1 42 | kind: Target 43 | metadata: 44 | name: 'prod' 45 | description: 'Production target' 46 | run: 47 | location: 'projects/iam-auth0/locations/us-east1' 48 | executionConfigs: 49 | - usages: 50 | - RENDER 51 | - DEPLOY 52 | serviceAccount: sso-dashboard-prod@iam-auth0.iam.gserviceaccount.com 53 | -------------------------------------------------------------------------------- /clouddeploy/skaffold.template.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | apiVersion: skaffold/v3alpha1 16 | kind: Config 17 | metadata: 18 | name: 'sso-dashboard' 19 | deploy: 20 | cloudrun: {} 21 | profiles: 22 | - name: 'dev' 23 | manifests: 24 | rawYaml: 25 | - 'sso-dashboard-dev.yaml' 26 | - name: 'staging' 27 | manifests: 28 | rawYaml: 29 | - 'sso-dashboard-staging.yaml' 30 | - name: 'prod' 31 | manifests: 32 | rawYaml: 33 | - 'sso-dashboard-prod.yaml' 34 | -------------------------------------------------------------------------------- /clouddeploy/sso-dashboard-dev.template.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | apiVersion: serving.knative.dev/v1 16 | kind: Service 17 | metadata: 18 | name: 'sso-dashboard-dev' 19 | annotations: 20 | run.googleapis.com/ingress: internal-and-cloud-load-balancing 21 | run.googleapis.com/description: 'https://sso.allizom.org' 22 | spec: 23 | template: 24 | metadata: 25 | annotations: 26 | autoscaling.knative.dev/minScale: '1' 27 | autoscaling.knative.dev/maxScale: '3' 28 | run.googleapis.com/cpu-throttling: 'false' 29 | run.googleapis.com/startup-cpu-boost: 'true' 30 | run.googleapis.com/vpc-access-connector: 'redis-connector' 31 | spec: 32 | containers: 33 | - name: 'sso-dashboard' 34 | image: 'app' 35 | command: 36 | - 'gunicorn' 37 | - 'dashboard.app:app' 38 | args: 39 | - '--worker-class=gevent' 40 | - '--bind=0.0.0.0:8000' 41 | - '--workers=3' 42 | - '--graceful-timeout=30' 43 | - '--timeout=60' 44 | - '--reload' 45 | - '--reload-extra-file=/dashboard/data/apps.yml' 46 | ports: 47 | - name: http1 48 | containerPort: 8000 49 | env: 50 | - name: 'TARGET' 51 | value: 'Staging' 52 | - name: TESTING 53 | value: False 54 | - name: CSRF_ENABLED 55 | value: True 56 | - name: PERMANENT_SESSION 57 | value: True 58 | - name: PERMANENT_SESSION_LIFETIME 59 | value: 86400 60 | - name: SESSION_COOKIE_HTTPONLY 61 | value: True 62 | - name: PREFERRED_URL_SCHEME 63 | value: https 64 | - name: OIDC_CLIENT_ID 65 | value: 2KNOUCxN8AFnGGjDCGtqiDIzq8MKXi2h 66 | - name: OIDC_DOMAIN 67 | value: idp-dev.iam.mozilla.com 68 | - name: SERVER_NAME 69 | value: sso.allizom.org 70 | - name: CDN 71 | value: https://cdn.sso.mozilla.com 72 | - name: S3_BUCKET 73 | value: sso-dashboard.configuration 74 | - name: FORBIDDEN_PAGE_PUBLIC_KEY 75 | value: "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0NCk1JSUNJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBZzhBTUlJQ0NnS0NBZ0VBcStWVXhsWWlENUF1aGh3a0hEb3kNCmFXV0YzcFlzQmxGaDlUendZeGNNR282TFdUODljb0xuUDdWVHlLbGdsTklmTG5KZDdqY2E5VUJ1QjhIVFBQYWoNCk5Ibk5UaUZ5UEZTbjFoaVhqSDJieUNDNnA1ZnByRWFEazV3YVpVNTgwaTRDaVlYcWtrWWdVbXVINW91Mnl4NW4NClZCVGJmcityZFQ0a0tRdi9Dek9ZR1o3K05NVUdXYTMvNXRMZklyRXZnV2tTTEluemtVZVhUS3huRSs5a1AvU2ENCkM4SDZsNnBKbm9oOVpiY3J4RGhkbVl6TEx2c0tIQ2tidmdCczNiaUFkSENzeHFEeFcxSGlOMzJYeEc4Y1pyc00NCjV1ZDdnbHNNY2VZWk82aENTZW4vckFUWmJkc3RETWNLa2YzMTBpUFgxRWF5ZzNPcDNZUlVTdkxzVmp5bmxQZmYNClRSVjFmQ2hJaXFwQWdnS2x4MXdqRUk2UVBuQWdpU1E0WEJweTFCK3FUSTltd1BhcE5yY2IzbkpFNDFNT0dEZGwNCmFLcHlMeGdaeEI4NmNKYTlVQXZGSEFOR08zRXA1Vmd0UjNoUStqWkY2RGFHUThjMHNyaHg4MTc4dWJybFY2NGsNCnVxK1ozVUZBZHhDbHZTRnc0eDVyTm1tV2dTN280OG9yMnhWdXVXMTQxNEZYTVBvaytDNUdabGd6ZG5zZ3cxWlQNCmhzTWNldG1temthUTlyeWxmYXVRR1gzMk5lZ3FlOWFyR0VGbXBqYjJUb2w0Tk1RLy83MzRuVFN2Q1lmL0o0Qi8NCnR0NjJzOXRBTU1ZdkpvN0ZRV2o0Qks3dUYvYmxTbTczYUVvVlFiNHdzWjJRWWpMeVlWcGh2UFprYVdaZHA0Wi8NCng0STlPT3lOYThtaGZkK3h0OU9uWXVzQ0F3RUFBUT09DQotLS0tLUVORCBQVUJMSUMgS0VZLS0tLS0NCg==\n" 76 | - name: REDIS_CONNECTOR 77 | value: 10.182.16.6:6379 78 | - name: AWS_DEFAULT_REGION 79 | value: us-west-2 80 | - name: ENVIRONMENT 81 | value: development 82 | - name: FLASK_DEBUG 83 | value: False 84 | - name: LANG 85 | value: en_US.utf8 86 | - name: FLASK_APP 87 | value: dashboard/app.py 88 | - name: OIDC_REDIRECT_URI 89 | value: https://sso.allizom.org/redirect_uri 90 | - name: AWS_SECRET_ACCESS_KEY 91 | valueFrom: 92 | secretKeyRef: 93 | key: latest 94 | name: sso-dashboard-aws-secret-access-key 95 | - name: SECRET_KEY 96 | valueFrom: 97 | secretKeyRef: 98 | key: latest 99 | name: sso-dashboard-dev-secret-key 100 | - name: OIDC_CLIENT_SECRET 101 | valueFrom: 102 | secretKeyRef: 103 | key: latest 104 | name: sso-dashboard-dev-oidc-client-secret 105 | - name: AWS_ACCESS_KEY_ID 106 | valueFrom: 107 | secretKeyRef: 108 | key: latest 109 | name: sso-dashboard-aws-access-key-id 110 | -------------------------------------------------------------------------------- /clouddeploy/sso-dashboard-prod.template.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | apiVersion: serving.knative.dev/v1 16 | kind: Service 17 | metadata: 18 | name: 'sso-dashboard-prod' 19 | annotations: 20 | run.googleapis.com/ingress: internal-and-cloud-load-balancing 21 | run.googleapis.com/description: 'https://sso.mozilla.com' 22 | spec: 23 | template: 24 | metadata: 25 | annotations: 26 | autoscaling.knative.dev/minScale: '2' 27 | autoscaling.knative.dev/maxScale: '3' 28 | run.googleapis.com/cpu-throttling: 'false' 29 | run.googleapis.com/startup-cpu-boost: 'true' 30 | run.googleapis.com/vpc-access-connector: 'redis-connector' 31 | spec: 32 | containers: 33 | - name: 'sso-dashboard' 34 | image: 'app' 35 | command: 36 | - 'gunicorn' 37 | - 'dashboard.app:app' 38 | args: 39 | - '--worker-class=gevent' 40 | - '--bind=0.0.0.0:8000' 41 | - '--workers=3' 42 | - '--graceful-timeout=30' 43 | - '--timeout=60' 44 | - '--reload' 45 | - '--reload-extra-file=/dashboard/data/apps.yml' 46 | ports: 47 | - name: http1 48 | containerPort: 8000 49 | env: 50 | - name: TARGET 51 | value: Prod 52 | - name: TESTING 53 | value: False 54 | - name: CSRF_ENABLED 55 | value: True 56 | - name: PERMANENT_SESSION 57 | value: True 58 | - name: PERMANENT_SESSION_LIFETIME 59 | value: 86400 60 | - name: SESSION_COOKIE_HTTPONLY 61 | value: True 62 | - name: PREFERRED_URL_SCHEME 63 | value: https 64 | - name: OIDC_CLIENT_ID 65 | value: UCOY390lYDxgj5rU8EeXRtN6EP005k7V 66 | - name: OIDC_DOMAIN 67 | value: auth.mozilla.auth0.com 68 | - name: SERVER_NAME 69 | value: sso.mozilla.com 70 | - name: CDN 71 | value: https://cdn.sso.mozilla.com 72 | - name: S3_BUCKET 73 | value: sso-dashboard.configuration 74 | - name: FORBIDDEN_PAGE_PUBLIC_KEY 75 | value: "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0NCk1JSUNJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBZzhBTUlJQ0NnS0NBZ0VBcStWVXhsWWlENUF1aGh3a0hEb3kNCmFXV0YzcFlzQmxGaDlUendZeGNNR282TFdUODljb0xuUDdWVHlLbGdsTklmTG5KZDdqY2E5VUJ1QjhIVFBQYWoNCk5Ibk5UaUZ5UEZTbjFoaVhqSDJieUNDNnA1ZnByRWFEazV3YVpVNTgwaTRDaVlYcWtrWWdVbXVINW91Mnl4NW4NClZCVGJmcityZFQ0a0tRdi9Dek9ZR1o3K05NVUdXYTMvNXRMZklyRXZnV2tTTEluemtVZVhUS3huRSs5a1AvU2ENCkM4SDZsNnBKbm9oOVpiY3J4RGhkbVl6TEx2c0tIQ2tidmdCczNiaUFkSENzeHFEeFcxSGlOMzJYeEc4Y1pyc00NCjV1ZDdnbHNNY2VZWk82aENTZW4vckFUWmJkc3RETWNLa2YzMTBpUFgxRWF5ZzNPcDNZUlVTdkxzVmp5bmxQZmYNClRSVjFmQ2hJaXFwQWdnS2x4MXdqRUk2UVBuQWdpU1E0WEJweTFCK3FUSTltd1BhcE5yY2IzbkpFNDFNT0dEZGwNCmFLcHlMeGdaeEI4NmNKYTlVQXZGSEFOR08zRXA1Vmd0UjNoUStqWkY2RGFHUThjMHNyaHg4MTc4dWJybFY2NGsNCnVxK1ozVUZBZHhDbHZTRnc0eDVyTm1tV2dTN280OG9yMnhWdXVXMTQxNEZYTVBvaytDNUdabGd6ZG5zZ3cxWlQNCmhzTWNldG1temthUTlyeWxmYXVRR1gzMk5lZ3FlOWFyR0VGbXBqYjJUb2w0Tk1RLy83MzRuVFN2Q1lmL0o0Qi8NCnR0NjJzOXRBTU1ZdkpvN0ZRV2o0Qks3dUYvYmxTbTczYUVvVlFiNHdzWjJRWWpMeVlWcGh2UFprYVdaZHA0Wi8NCng0STlPT3lOYThtaGZkK3h0OU9uWXVzQ0F3RUFBUT09DQotLS0tLUVORCBQVUJMSUMgS0VZLS0tLS0NCg==\n" 76 | - name: REDIS_CONNECTOR 77 | value: 10.182.16.6:6379 78 | - name: AWS_DEFAULT_REGION 79 | value: us-west-2 80 | - name: ENVIRONMENT 81 | value: production 82 | - name: FLASK_DEBUG 83 | value: False 84 | - name: LANG 85 | value: en_US.utf8 86 | - name: FLASK_APP 87 | value: dashboard/app.py 88 | - name: OIDC_REDIRECT_URI 89 | value: https://sso.mozilla.com/redirect_uri 90 | - name: AWS_SECRET_ACCESS_KEY 91 | valueFrom: 92 | secretKeyRef: 93 | key: latest 94 | name: sso-dashboard-aws-secret-access-key 95 | - name: SECRET_KEY 96 | valueFrom: 97 | secretKeyRef: 98 | key: latest 99 | name: sso-dashboard-prod-secret-key 100 | - name: OIDC_CLIENT_SECRET 101 | valueFrom: 102 | secretKeyRef: 103 | key: latest 104 | name: sso-dashboard-prod-oidc-client-secret 105 | - name: AWS_ACCESS_KEY_ID 106 | valueFrom: 107 | secretKeyRef: 108 | key: latest 109 | name: sso-dashboard-aws-access-key-id 110 | -------------------------------------------------------------------------------- /clouddeploy/sso-dashboard-staging.template.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | apiVersion: serving.knative.dev/v1 16 | kind: Service 17 | metadata: 18 | name: 'sso-dashboard-staging' 19 | annotations: 20 | run.googleapis.com/ingress: internal-and-cloud-load-balancing 21 | run.googleapis.com/description: 'https://staging.sso.mozilla.com' 22 | spec: 23 | template: 24 | metadata: 25 | annotations: 26 | autoscaling.knative.dev/minScale: '2' 27 | autoscaling.knative.dev/maxScale: '3' 28 | run.googleapis.com/cpu-throttling: 'false' 29 | run.googleapis.com/startup-cpu-boost: 'true' 30 | run.googleapis.com/vpc-access-connector: 'redis-connector' 31 | spec: 32 | containers: 33 | - name: 'sso-dashboard' 34 | image: 'app' 35 | command: 36 | - 'gunicorn' 37 | - 'dashboard.app:app' 38 | args: 39 | - '--worker-class=gevent' 40 | - '--bind=0.0.0.0:8000' 41 | - '--workers=3' 42 | - '--graceful-timeout=30' 43 | - '--timeout=60' 44 | - '--reload' 45 | - '--reload-extra-file=/dashboard/data/apps.yml' 46 | ports: 47 | - name: http1 48 | containerPort: 8000 49 | env: 50 | - name: TARGET 51 | value: Prod 52 | - name: TESTING 53 | value: False 54 | - name: CSRF_ENABLED 55 | value: True 56 | - name: PERMANENT_SESSION 57 | value: True 58 | - name: PERMANENT_SESSION_LIFETIME 59 | value: 86400 60 | - name: SESSION_COOKIE_HTTPONLY 61 | value: True 62 | - name: PREFERRED_URL_SCHEME 63 | value: https 64 | - name: OIDC_CLIENT_ID 65 | value: UCOY390lYDxgj5rU8EeXRtN6EP005k7V 66 | - name: OIDC_DOMAIN 67 | value: auth.mozilla.auth0.com 68 | - name: SERVER_NAME 69 | value: staging.sso.mozilla.com 70 | - name: CDN 71 | value: https://cdn.sso.mozilla.com 72 | - name: S3_BUCKET 73 | value: sso-dashboard.configuration 74 | - name: FORBIDDEN_PAGE_PUBLIC_KEY 75 | value: "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0NCk1JSUNJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBZzhBTUlJQ0NnS0NBZ0VBcStWVXhsWWlENUF1aGh3a0hEb3kNCmFXV0YzcFlzQmxGaDlUendZeGNNR282TFdUODljb0xuUDdWVHlLbGdsTklmTG5KZDdqY2E5VUJ1QjhIVFBQYWoNCk5Ibk5UaUZ5UEZTbjFoaVhqSDJieUNDNnA1ZnByRWFEazV3YVpVNTgwaTRDaVlYcWtrWWdVbXVINW91Mnl4NW4NClZCVGJmcityZFQ0a0tRdi9Dek9ZR1o3K05NVUdXYTMvNXRMZklyRXZnV2tTTEluemtVZVhUS3huRSs5a1AvU2ENCkM4SDZsNnBKbm9oOVpiY3J4RGhkbVl6TEx2c0tIQ2tidmdCczNiaUFkSENzeHFEeFcxSGlOMzJYeEc4Y1pyc00NCjV1ZDdnbHNNY2VZWk82aENTZW4vckFUWmJkc3RETWNLa2YzMTBpUFgxRWF5ZzNPcDNZUlVTdkxzVmp5bmxQZmYNClRSVjFmQ2hJaXFwQWdnS2x4MXdqRUk2UVBuQWdpU1E0WEJweTFCK3FUSTltd1BhcE5yY2IzbkpFNDFNT0dEZGwNCmFLcHlMeGdaeEI4NmNKYTlVQXZGSEFOR08zRXA1Vmd0UjNoUStqWkY2RGFHUThjMHNyaHg4MTc4dWJybFY2NGsNCnVxK1ozVUZBZHhDbHZTRnc0eDVyTm1tV2dTN280OG9yMnhWdXVXMTQxNEZYTVBvaytDNUdabGd6ZG5zZ3cxWlQNCmhzTWNldG1temthUTlyeWxmYXVRR1gzMk5lZ3FlOWFyR0VGbXBqYjJUb2w0Tk1RLy83MzRuVFN2Q1lmL0o0Qi8NCnR0NjJzOXRBTU1ZdkpvN0ZRV2o0Qks3dUYvYmxTbTczYUVvVlFiNHdzWjJRWWpMeVlWcGh2UFprYVdaZHA0Wi8NCng0STlPT3lOYThtaGZkK3h0OU9uWXVzQ0F3RUFBUT09DQotLS0tLUVORCBQVUJMSUMgS0VZLS0tLS0NCg==\n" 76 | - name: REDIS_CONNECTOR 77 | value: 10.182.16.6:6379 78 | - name: AWS_DEFAULT_REGION 79 | value: us-west-2 80 | - name: ENVIRONMENT 81 | value: staging 82 | - name: FLASK_DEBUG 83 | value: False 84 | - name: LANG 85 | value: en_US.utf8 86 | - name: FLASK_APP 87 | value: dashboard/app.py 88 | - name: OIDC_REDIRECT_URI 89 | value: https://sso.mozilla.com/redirect_uri 90 | - name: AWS_SECRET_ACCESS_KEY 91 | valueFrom: 92 | secretKeyRef: 93 | key: latest 94 | name: sso-dashboard-aws-secret-access-key 95 | - name: SECRET_KEY 96 | valueFrom: 97 | secretKeyRef: 98 | key: latest 99 | name: sso-dashboard-prod-secret-key 100 | - name: OIDC_CLIENT_SECRET 101 | valueFrom: 102 | secretKeyRef: 103 | key: latest 104 | name: sso-dashboard-prod-oidc-client-secret 105 | - name: AWS_ACCESS_KEY_ID 106 | valueFrom: 107 | secretKeyRef: 108 | key: latest 109 | name: sso-dashboard-aws-access-key-id 110 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: redis 4 | ports: 5 | - 6379:6379 6 | 7 | sso-dashboard: 8 | build: . 9 | env_file: envfile 10 | ports: 11 | - 8000:8000 12 | volumes: 13 | - ./dashboard:/dashboard 14 | 15 | ## Uncomment this section to override the defaults as they are set in Dockerfile 16 | # entrypoint: "gunicorn" 17 | # command: 18 | # - 'dashboard.app:app' 19 | # - '--worker-class' 20 | # - gevent 21 | # - '--bind' 22 | # - '0.0.0.0:8000' 23 | # - '--workers=2' 24 | # - '--log-level=debug' 25 | # - '--max-requests=1000' 26 | # - '--max-requests-jitter=50' 27 | # - '--graceful-timeout=30' 28 | # - '--timeout=60' 29 | # - '--log-level=debug' 30 | # - '--error-logfile=-' 31 | -------------------------------------------------------------------------------- /dashboard/__init__.py: -------------------------------------------------------------------------------- 1 | """Mozilla Single Signon Dashboard.""" 2 | 3 | __author__ = """Andrew Krug""" 4 | __email__ = "akrug@mozilla.com" 5 | __version__ = "0.0.1" 6 | 7 | 8 | __all__ = ["app", "auth", "config", "models", "s3", "utils", "vanity"] 9 | -------------------------------------------------------------------------------- /dashboard/app.py: -------------------------------------------------------------------------------- 1 | """SSO Dashboard App File.""" 2 | 3 | import json 4 | import logging 5 | import logging.config 6 | import mimetypes 7 | import os 8 | import redis 9 | import traceback 10 | import yaml 11 | 12 | from flask import Flask 13 | from flask import jsonify 14 | from flask import redirect 15 | from flask import render_template 16 | from flask import request 17 | from flask import send_from_directory 18 | from flask import session 19 | from flask import url_for 20 | from flask.sessions import SessionInterface 21 | 22 | from flask_assets import Bundle # type: ignore 23 | from flask_assets import Environment # type: ignore 24 | from flask_session.redis import RedisSessionInterface # type: ignore 25 | from flask_talisman import Talisman # type: ignore 26 | 27 | from dashboard import oidc_auth 28 | from dashboard import config 29 | from dashboard import vanity 30 | 31 | from dashboard.csp import DASHBOARD_CSP 32 | from dashboard.models.user import User 33 | from dashboard.models.user import FakeUser 34 | from dashboard.op.yaml_loader import Application 35 | from dashboard.models.tile import CDNTransfer 36 | 37 | 38 | logging.config.fileConfig("dashboard/logging.ini") 39 | 40 | app_config = config.Default() 41 | 42 | if app_config.DEBUG: 43 | # Set the log level to DEBUG for all defined loggers 44 | for logger_name in logging.root.manager.loggerDict.keys(): 45 | logging.getLogger(logger_name).setLevel("DEBUG") 46 | 47 | app = Flask(__name__) 48 | app.config.from_object(app_config) 49 | 50 | talisman = Talisman(app, content_security_policy=DASHBOARD_CSP, force_https=False) 51 | 52 | app_list = CDNTransfer(app_config) 53 | 54 | 55 | def session_configure(app: Flask) -> SessionInterface: 56 | """ 57 | We should try doing what our dependencies prefer, falling back to what we 58 | want to do only as a last resort. That is to say, try using a connection 59 | string _first_, then do our logic. 60 | 61 | This function will either return a _verified_ connection or raise an 62 | exception (failing fast). 63 | 64 | Considerations for the future: 65 | * Auth 66 | """ 67 | try: 68 | client = redis.Redis.from_url(app.config["REDIS_CONNECTOR"]) 69 | except ValueError: 70 | host, _, port = app.config["REDIS_CONNECTOR"].partition(":") 71 | client = redis.Redis(host=host, port=int(port)) 72 | # [redis.Redis.ping] will raise an exception if it can't connect anyways, 73 | # but at least this way we make use of it's return value. Feels weird to 74 | # not? 75 | # 76 | # redis.Redis.ping: https://github.com/redis/redis-py/blob/00f5be420b397adfa1b9aa9c2761f7d8a27c0a9a/redis/commands/core.py#L1206 77 | assert client.ping(), "Could not ping Redis" 78 | return RedisSessionInterface(app, client=client) 79 | 80 | 81 | app.session_interface = session_configure(app) 82 | 83 | assets = Environment(app) 84 | js = Bundle("js/base.js", filters="jsmin", output="js/gen/packed.js") 85 | assets.register("js_all", js) 86 | 87 | sass = Bundle("css/base.scss", filters="scss") 88 | css = Bundle(sass, filters="cssmin", output="css/gen/all.css") 89 | assets.register("css_all", css) 90 | 91 | # Hack to support serving .svg 92 | mimetypes.add_type("image/svg+xml", ".svg") 93 | 94 | oidc_config = config.OIDC() 95 | authentication = oidc_auth.OpenIDConnect(oidc_config) 96 | oidc = authentication.get_oidc(app) 97 | 98 | vanity_router = vanity.Router(app, app_list).setup() 99 | 100 | 101 | @app.route("/favicon.ico") 102 | def favicon(): 103 | return send_from_directory(os.path.join(app.root_path, "static/img"), "favicon.ico") 104 | 105 | 106 | @app.route("/") 107 | def home(): 108 | if app.config["ENVIRONMENT"] == "local": 109 | return redirect("dashboard", code=302) 110 | 111 | url = request.url.replace("http://", "https://", 1) 112 | return redirect(url + "dashboard", code=302) 113 | 114 | 115 | @app.route("/csp_report", methods=["POST"]) 116 | def csp_report(): 117 | return "200" 118 | 119 | 120 | @app.route("/version", methods=["GET"]) 121 | def get_version(): 122 | with open("/version.json", "r") as version: 123 | v = version.read().replace("\n", "") 124 | return jsonify(build_version=v) 125 | 126 | 127 | # XXX This needs to load the schema from a better location 128 | # See also https://github.com/mozilla/iam-project-backlog/issues/161 129 | @app.route("/claim") 130 | def claim(): 131 | """Show the user schema - this path is refered to by 132 | our OIDC Claim namespace, i.e.: https://sso.mozilla.com/claim/*""" 133 | return redirect("https://github.com/mozilla-iam/cis/blob/master/cis/schema.json", code=302) 134 | 135 | 136 | # Flask Error Handlers 137 | @app.errorhandler(404) 138 | def page_not_found(error): 139 | if request.url is not None: 140 | app.logger.error("A 404 has been generated for {route}".format(route=request.url)) 141 | return render_template("404.html"), 404 142 | 143 | 144 | @app.errorhandler(Exception) 145 | def handle_exception(e): 146 | 147 | # Capture the traceback 148 | tb_str = traceback.format_exc() 149 | 150 | # Log the error with traceback 151 | app.logger.error("An error occurred: %s\n%s", str(e), tb_str) 152 | 153 | response = {"error": "An internal error occurred", "message": str(e)} 154 | return jsonify(response), 500 155 | 156 | 157 | @app.route("/forbidden") 158 | def forbidden(): 159 | """Route to render error page.""" 160 | if "error" not in request.args: 161 | return render_template("forbidden.html"), 500 162 | try: 163 | tv = oidc_auth.TokenVerification( 164 | jws=request.args.get("error").encode(), 165 | public_key=app.config["FORBIDDEN_PAGE_PUBLIC_KEY"], 166 | ) 167 | except oidc_auth.TokenError: 168 | app.logger.exception("Could not validate JWS from IdP") 169 | return render_template("forbidden.html"), 500 170 | app.logger.warning( 171 | f"{tv.error_code} for {tv.client} (connection: {tv.connection}, preferred connection: {tv.preferred_connection_name})" 172 | ) 173 | return render_template("forbidden.html", message=tv.error_message()), 400 174 | 175 | 176 | @app.route("/logout") 177 | @oidc.oidc_logout 178 | def logout(): 179 | """ 180 | Uses the RP-Initiated Logout End Session Endpoint [0] if the app was 181 | started up with knowledge of it. Flask-pyoidc will make use of this 182 | endpoint if it's in the provider metadata [1]. 183 | 184 | If the app was _not_ started with the End Session Endpoint, then we'll 185 | fallback to using the `v2/logout` [2] endpoint. 186 | 187 | These two methods cover all cases where: 188 | 189 | * the tenant does not have the End Session Endpoint turned on/off; 190 | * the Universal Login page has/has not been customized. 191 | 192 | Note: As the Auth0 docs state [3], this _does not_ log users out of all 193 | applications. This simply ends their session with Auth0 and clears their 194 | SSO Dashboard session. Refer to the docs on what we'd need to do to achieve 195 | a global logout. 196 | 197 | [0]: https://auth0.com/docs/authenticate/login/logout/log-users-out-of-auth0#example 198 | [1]: https://github.com/zamzterz/Flask-pyoidc/blob/26b123572cba0b3fa84482c6c0270900042a73c9/src/flask_pyoidc/flask_pyoidc.py#L263 199 | [2]: https://auth0.com/docs/api/authentication#auth0-logout 200 | [3]: https://manage.mozilla-dev.auth0.com/docs/authenticate/login/logout/log-users-out-of-applications 201 | """ 202 | try: 203 | has_provider_endpoint = oidc.clients["default"].provider_end_session_endpoint is not None 204 | except (AttributeError, KeyError): 205 | has_provider_endpoint = False 206 | if has_provider_endpoint: 207 | app.logger.info("Used provider_end_session_endpoint for logout") 208 | return render_template("signout.html") 209 | # Old-school redirect. If we get here this means we haven't enabled the 210 | # RP-initiated logout end session endpoint on Auth0, and so we need to do 211 | # manual logout (in a non-breaking way). 212 | app.logger.info("Redirecting to v2/logout") 213 | # Build up the logout and signout URLs 214 | signout_url = f"{app.config["PREFERRED_URL_SCHEME"]}://{app.config["SERVER_NAME"]}{url_for("signout")}" 215 | logout_url = ( 216 | f"https://{oidc_config.OIDC_DOMAIN}/v2/logout?client_id={oidc_config.OIDC_CLIENT_ID}&returnTo={signout_url}" 217 | ) 218 | return redirect(logout_url, code=302) 219 | 220 | 221 | @app.route("/autologin-settings") 222 | def showautologinsettings(): 223 | """ 224 | Redirect to NLX Auto-login Settings page 225 | """ 226 | autologin_settings_url = "https://{}/login?client={}&action=autologin_settings".format( 227 | oidc_config.OIDC_DOMAIN, oidc_config.OIDC_CLIENT_ID 228 | ) 229 | return redirect(autologin_settings_url, code=302) 230 | 231 | 232 | @app.route("/signout.html") 233 | def signout(): 234 | app.logger.info("Signout messaging displayed.") 235 | return render_template("signout.html") 236 | 237 | 238 | @app.route("/dashboard") 239 | @oidc.oidc_auth("default") 240 | def dashboard(): 241 | """Primary dashboard the users will interact with.""" 242 | app.logger.info("User: {} authenticated proceeding to dashboard.".format(session.get("id_token")["sub"])) 243 | 244 | # TODO: Refactor rules later to support full id_conformant session 245 | session["userinfo"]["user_id"] = session.get("id_token")["sub"] 246 | 247 | # This checks the CDN for any updates to apps.yml 248 | # If an update is found, all gunicorn workers will be reloaded 249 | app_list.sync_config() 250 | 251 | user = User(session, app.config) 252 | apps = user.apps(Application(app_list.apps_yml).apps) 253 | 254 | return render_template("dashboard.html", config=app.config, user=user, apps=apps) 255 | 256 | 257 | @app.route("/styleguide/dashboard") 258 | def styleguide_dashboard(): 259 | user = FakeUser(app.config) 260 | apps = user.apps(Application(app_list.apps_yml).apps) 261 | 262 | return render_template("dashboard.html", config=app.config, user=user, apps=apps) 263 | 264 | 265 | @app.route("/styleguide/notifications") 266 | @oidc.oidc_auth("default") 267 | def styleguide_notifications(): 268 | user = FakeUser(app.config) 269 | return render_template("notifications.html", config=app.config, user=user) 270 | 271 | 272 | """useful endpoint for debugging""" 273 | 274 | 275 | @app.route("/info") 276 | @oidc.oidc_auth("default") 277 | def info(): 278 | """Return the JSONified user session for debugging.""" 279 | return jsonify( 280 | id_token=session.get("id_token"), 281 | userinfo=session.get("userinfo"), 282 | ) 283 | 284 | 285 | @app.route("/about") 286 | def about(): 287 | return render_template("about.html") 288 | 289 | 290 | @app.route("/contribute.json") 291 | def contribute_lower(): 292 | data = { 293 | "name": "sso-dashboard by Mozilla", 294 | "description": "A single signon dashboard for auth0.", 295 | "repository": { 296 | "url": "https://github.com/mozilla-iam/sso-dashboard", 297 | "license": "MPL2", 298 | }, 299 | "participate": { 300 | "home": "https://github.com/mozilla-iam/sso-dashboard", 301 | "irc": "irc://irc.mozilla.org/#infosec", 302 | "irc-contacts": ["Andrew"], 303 | }, 304 | "bugs": { 305 | "list": "https://github.com/mozilla-iam/sso-dashboard/issues", 306 | "report": "https://github.com/mozilla-iam/sso-dashboard/issues/new", 307 | "mentored": "https://github.com/mozilla-iam/sso-dashboard/issues?q=is%3Aissue+is%3Aclosed", # noqa 308 | }, 309 | "urls": { 310 | "prod": "https://sso.mozilla.com/", 311 | "stage": "https://sso.allizom.org/", 312 | }, 313 | "keywords": ["python", "html5", "jquery", "mui-css", "sso", "auth0"], 314 | } 315 | 316 | return jsonify(data) 317 | 318 | 319 | if __name__ == "__main__": 320 | app.run() 321 | -------------------------------------------------------------------------------- /dashboard/config.py: -------------------------------------------------------------------------------- 1 | """Configuration loader for different environments.""" 2 | 3 | import os 4 | import base64 5 | import datetime 6 | 7 | 8 | class Default: 9 | """Defaults for the configuration objects.""" 10 | 11 | ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "local") 12 | 13 | # Flask makes use of the `FLASK_DEBUG` environment variable, with or 14 | # without using `Flask.config.from_prefixed_env` (a method we don't 15 | # use). 16 | DEBUG: bool = os.environ.get("FLASK_DEBUG", "True") == "True" 17 | 18 | TESTING: bool = os.environ.get("TESTING", "True") == "True" 19 | 20 | CSRF_ENABLED: bool = os.environ.get("CSRF_ENABLED", "True") == "True" 21 | PERMANENT_SESSION: bool = os.environ.get("PERMANENT_SESSION", "True") == "True" 22 | PERMANENT_SESSION_LIFETIME: datetime.timedelta = datetime.timedelta( 23 | seconds=int(os.environ.get("PERMANENT_SESSION_LIFETIME", "86400")) 24 | ) 25 | 26 | SESSION_COOKIE_SAMESITE: str = os.environ.get("SESSION_COOKIE_SAMESITE", "lax") 27 | SESSION_COOKIE_HTTPONLY: bool = os.environ.get("SESSION_COOKIE_HTTPONLY", "True") == "True" 28 | 29 | SECRET_KEY: str 30 | SERVER_NAME: str = os.environ.get("SERVER_NAME", "localhost:8000") 31 | SESSION_COOKIE_NAME: str 32 | CDN: str 33 | 34 | S3_BUCKET: str 35 | FORBIDDEN_PAGE_PUBLIC_KEY: bytes 36 | 37 | PREFERRED_URL_SCHEME: str = os.environ.get("PREFERRED_URL_SCHEME", "https") 38 | REDIS_CONNECTOR: str 39 | 40 | def __init__(self): 41 | self.SESSION_COOKIE_NAME = f"{self.SERVER_NAME}_session" 42 | self.CDN = os.environ.get("CDN", f"https://cdn.{self.SERVER_NAME}") 43 | self.REDIS_CONNECTOR = os.environ["REDIS_CONNECTOR"] 44 | self.SECRET_KEY = os.environ["SECRET_KEY"] 45 | self.S3_BUCKET = os.environ["S3_BUCKET"] 46 | self.FORBIDDEN_PAGE_PUBLIC_KEY = base64.b64decode(os.environ["FORBIDDEN_PAGE_PUBLIC_KEY"]) 47 | 48 | 49 | class OIDC: 50 | """Convienience Object for returning required vars to flask.""" 51 | 52 | OIDC_DOMAIN: str 53 | OIDC_CLIENT_ID: str 54 | OIDC_CLIENT_SECRET: str 55 | OIDC_REDIRECT_URI: str 56 | LOGIN_URL: str 57 | 58 | def __init__(self): 59 | """General object initializer.""" 60 | self.OIDC_DOMAIN = os.environ["OIDC_DOMAIN"] 61 | self.OIDC_CLIENT_ID = os.environ["OIDC_CLIENT_ID"] 62 | self.OIDC_CLIENT_SECRET = os.environ["OIDC_CLIENT_SECRET"] 63 | # Check for a prefixed environment variable, otherwise fallback to the 64 | # unprefixed one. 65 | if redirect_uri := os.environ.get("OIDC_REDIRECT_URI"): 66 | self.OIDC_REDIRECT_URI = redirect_uri 67 | else: 68 | self.OIDC_REDIRECT_URI = os.environ["OIDC_REDIRECT_URI"] 69 | self.LOGIN_URL = f"https://{self.OIDC_DOMAIN}/login?client={self.OIDC_CLIENT_ID}" 70 | 71 | @property 72 | def client_id(self): 73 | return self.OIDC_CLIENT_ID 74 | 75 | @property 76 | def client_secret(self): 77 | return self.OIDC_CLIENT_SECRET 78 | -------------------------------------------------------------------------------- /dashboard/csp.py: -------------------------------------------------------------------------------- 1 | DASHBOARD_CSP = { 2 | "default-src": ["'self'"], 3 | "script-src": [ 4 | "'self'", 5 | "https://ajax.googleapis.com", 6 | "https://fonts.googleapis.com", 7 | "https://*.googletagmanager.com", 8 | "https://tagmanager.google.com", 9 | "https://*.google-analytics.com", 10 | "https://cdn.sso.mozilla.com", 11 | "https://cdn.sso.allizom.org", 12 | ], 13 | "style-src": [ 14 | "'self'", 15 | "https://ajax.googleapis.com", 16 | "https://fonts.googleapis.com", 17 | "https://cdn.sso.mozilla.com", 18 | "https://cdn.sso.allizom.org", 19 | ], 20 | "img-src": [ 21 | "'self'", 22 | "https://*.mozillians.org", 23 | "https://cdn.sso.mozilla.com", 24 | "https://cdn.sso.allizom.org", 25 | "https://*.google-analytics.com", 26 | "https://*.gravatar.com", 27 | "https://i0.wp.com/", 28 | "https://i1.wp.com", 29 | ], 30 | "font-src": [ 31 | "'self'", 32 | "https://fonts.googleapis.com", 33 | "https://fonts.gstatic.com", 34 | "https://cdn.sso.mozilla.com", 35 | "https://cdn.sso.allizom.org", 36 | ], 37 | } 38 | -------------------------------------------------------------------------------- /dashboard/data/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/dashboard/data/.keep -------------------------------------------------------------------------------- /dashboard/logging.ini: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root,gunicorn.error,gunicorn.access,flask_app,werkzeug 3 | 4 | [handlers] 5 | keys=console 6 | 7 | [formatters] 8 | keys=json 9 | 10 | [logger_root] 11 | level=INFO 12 | handlers=console 13 | disable_existing_loggers=False 14 | 15 | [logger_gunicorn.error] 16 | level=INFO 17 | qualname=gunicorn.error 18 | propagate=0 19 | handlers=console 20 | 21 | [logger_gunicorn.access] 22 | level=INFO 23 | qualname=gunicorn.access 24 | propagate=0 25 | handlers=console 26 | 27 | [logger_flask_app] 28 | level=INFO 29 | qualname=flask_app 30 | propagate=0 31 | handlers=console 32 | 33 | [logger_werkzeug] 34 | level=INFO 35 | qualname=werkzeug 36 | propagate=0 37 | handlers=console 38 | 39 | [handler_console] 40 | class=logging.StreamHandler 41 | formatter=json 42 | args=(sys.stderr,) 43 | 44 | [formatter_json] 45 | format={"time": "%(asctime)s", "level": "%(levelname)s", "process_id": %(process)d, "message": "%(message)s", "name": "%(filename)s:%(name)s:%(funcName)s:%(lineno)s"} 46 | -------------------------------------------------------------------------------- /dashboard/models/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["tile", "user", "apps"] 2 | -------------------------------------------------------------------------------- /dashboard/models/apps.py: -------------------------------------------------------------------------------- 1 | """ 2 | These specs are copied to sso-dashboard-configuration/tests/test_yaml.py. 3 | 4 | If you change these definitions, copy them over there as well. 5 | """ 6 | 7 | from typing import Literal, NotRequired, TypedDict 8 | 9 | 10 | class Application(TypedDict): 11 | """ 12 | A schema(ish) definition for what we expect typical applications to 13 | look like. 14 | """ 15 | 16 | # RP's name, easier for humans when reading this file. 17 | name: str 18 | 19 | # The access provider name (OP: Open Id Connect Provider). They are all 20 | # "auth0" currently but that might change someday. 21 | op: Literal["auth0"] 22 | 23 | # This is the URL that a user must visit to be logged into the RP. This 24 | # URL would either be the URL of the login button on the site (if it has 25 | # one), or the URL that a user gets redirected to when they visit a 26 | # protected page while unauthenticated. 27 | url: str 28 | 29 | # A custom logo to be displayed for this RP on the SSO Dashboard. 30 | # Loaded from the same CDN as `apps.yml`, under the `images/` directory. 31 | logo: str 32 | 33 | # If true, will be displayed on the SSO Dashboard 34 | display: bool 35 | 36 | # The access provider's `client_id` for this RP. 37 | client_id: NotRequired[str] 38 | 39 | # An URL that people can bookmark on the SSO Dashboard to login directly to 40 | # that RP (i.e. not the RP frontpage). 41 | # e.g. /box -> redirect to Box's authentication page. 42 | vanity_url: NotRequired[list[str]] 43 | 44 | # The list of users and groups allowed to access this RP. 45 | # If both authorize_users and authorized_groups are empty, everyone is 46 | # allowed. 47 | # If one is empty and the other has content, only the members of the non 48 | # empty one are allowed. 49 | # If both have content, the union of everyone in both are allowed. 50 | # 51 | # The dashboard uses this to decide if we should show the tile to the user. 52 | # Our IdP uses this to decide if a user is authorized to access this 53 | # application. 54 | authorized_users: list[str] 55 | authorized_groups: list[str] 56 | 57 | # See: https://infosec.mozilla.org/guidelines/risk/standard_levels 58 | # 59 | # AAL values below are available at the IAM well-known endpoint 60 | # See: (https://auth.mozilla.org/.well-known/mozilla-iam) 61 | # 62 | # AAI is Authenticator Assurance Indicator: A Standard level which 63 | # indicates the amount confidence in the authentication mechanism used is 64 | # required to access this RP. It is enforced by the Access Provider. 65 | # E.g. MEDIUM may mean 2FA required 66 | AAL: NotRequired[Literal["LOW", "MEDIUM", "MAXIMUM"]] 67 | 68 | 69 | class AppEntry(TypedDict): 70 | """An item in the `apps` list.""" 71 | 72 | application: Application 73 | 74 | 75 | class Apps(TypedDict): 76 | """The top-level definition of the apps.yml""" 77 | 78 | apps: list[AppEntry] 79 | -------------------------------------------------------------------------------- /dashboard/models/tile.py: -------------------------------------------------------------------------------- 1 | """Governs loading all tile displayed to the user in the Dashboard.""" 2 | 3 | import logging 4 | import os 5 | import urllib3 6 | from urllib3.exceptions import HTTPError 7 | 8 | logger = logging.getLogger() 9 | 10 | 11 | class CDNTransfer(object): 12 | """Download apps.yaml from CDN""" 13 | 14 | def __init__(self, app_config): 15 | """ 16 | Handles fetching and loading the apps.yml file 17 | When a CDNTransfer Object is instantiated, the CDN is checked for 18 | an updated version of apps.yml. If a ETags are mismatched then 19 | a new version is available. We download it which causes the worker 20 | to reload. If the Etags of the CDN matches that on disk, we 21 | simply read from the disk. 22 | """ 23 | self.app_config = app_config 24 | self.apps_yml = None 25 | self.url = self.app_config.CDN + "/apps.yml" 26 | # Check if there is an update to apps.yml 27 | self.sync_config() 28 | 29 | def is_updated(self): 30 | """Compare etag of what is in CDN to what is on disk.""" 31 | http = urllib3.PoolManager() 32 | response = http.request("HEAD", self.url) 33 | 34 | if response.headers["ETag"] != self._etag(): 35 | logger.warning("Etags do not match") 36 | return True 37 | else: 38 | return False 39 | 40 | def _update_etag(self, etag): 41 | """Update the etag file.""" 42 | this_dir = os.path.dirname(__file__) 43 | filename = os.path.join(this_dir, "../data/{name}").format(name="apps.yml-etag") 44 | with open(filename, "w+") as c: 45 | c.write(etag) 46 | 47 | def _etag(self): 48 | """get the etag from the file""" 49 | this_dir = os.path.dirname(__file__) 50 | filename = os.path.join(this_dir, "../data/{name}").format(name="apps.yml-etag") 51 | try: 52 | with open(filename, "r") as f: 53 | # What happens if we read nothing? 54 | etag = f.read() 55 | return etag 56 | # This can fail with 57 | # * FileNotFoundError 58 | # * PermissionError 59 | # TODO(bhee): Do we want to be specific? 60 | except Exception: 61 | """If the etag file is not found return a fake etag.""" 62 | logger.exception("Error fetching etag") 63 | return "12345678" 64 | 65 | def _download_config(self): 66 | """Download the apps.yml from the CDN.""" 67 | http = urllib3.PoolManager() 68 | 69 | try: 70 | logger.info("Downloading apps.yml from CDN") 71 | response = http.request("GET", self.url) 72 | if response.status != 200: 73 | raise HTTPError(f"HTTP request failed with status {response.status}") 74 | except HTTPError as exc: 75 | exc.add_note("Request for apps.yml failed") 76 | raise 77 | 78 | this_dir = os.path.dirname(__file__) 79 | filename = os.path.join(this_dir, "../data/{name}").format(name="apps.yml") 80 | 81 | try: 82 | # As soon as this file is closed, gunicorn should reload the workers 83 | with open(filename, "wb") as file: 84 | file.write(response.data) 85 | # Ensure all data is flushed to disk 86 | file.flush() 87 | # Ensure data is written to disk before proceeding 88 | os.fsync(file.fileno()) 89 | # It is very important that the ETag file is written before we close 90 | # apps.yml file. Otherwise, this may cause a reload loop 91 | self._update_etag(response.headers["ETag"]) 92 | except Exception as exc: 93 | exc.add_note("An error occurred while attempting to write apps.yml") 94 | raise 95 | 96 | def _load_apps_yml(self): 97 | """Load the apps.yml file on disk""" 98 | this_dir = os.path.dirname(__file__) 99 | filename = os.path.join(this_dir, "../data/{name}").format(name="apps.yml") 100 | with open(filename, "r") as file: 101 | logger.info("Loading apps.yml from disk") 102 | self.apps_yml = file.read() 103 | 104 | def sync_config(self): 105 | """Determines if the config file is updated and if so fetches the new config.""" 106 | try: 107 | # Check if the CDN has an updated apps.yml 108 | if self.is_updated(): 109 | logger.info("Config file is updated fetching new config.") 110 | self._download_config() 111 | except Exception: 112 | logger.exception("Problem fetching config file") 113 | 114 | # Load the apps.yml file into self.apps_list 115 | # if it isn't already loaded 116 | try: 117 | if not self.apps_yml: 118 | self._load_apps_yml() 119 | except Exception: 120 | logger.exception("Problem loading the config file") 121 | 122 | 123 | class Tile(object): 124 | def __init__(self, app_config): 125 | """ 126 | :param app_config: Flask app config object. 127 | """ 128 | self.app_config = app_config 129 | 130 | def find_by_user(self, email): 131 | """Return all the tiles for a given user.""" 132 | pass 133 | 134 | def find_by_group(self, group): 135 | """Return all the tiles for a given group.""" 136 | pass 137 | 138 | def find_by_profile(self, userprofile): 139 | """Takes a flask_session of userinfo and return all apps for user and groups.""" 140 | pass 141 | 142 | def all(self): 143 | """Return all apps.""" 144 | pass 145 | -------------------------------------------------------------------------------- /dashboard/models/user.py: -------------------------------------------------------------------------------- 1 | """User class that governs maniuplation of session['userdata']""" 2 | 3 | import logging 4 | import time 5 | from faker import Faker 6 | from typeguard import check_type, TypeCheckError 7 | 8 | from dashboard.models.apps import Application 9 | 10 | fake = Faker() 11 | logger = logging.getLogger() 12 | 13 | 14 | class User(object): 15 | def __init__(self, session, app_config): 16 | """Constructor takes user session.""" 17 | self.id_token = session.get("id_token", None) 18 | self.app_config = app_config 19 | self.userinfo = session.get("userinfo") 20 | 21 | def email(self): 22 | try: 23 | email = self.userinfo.get("email") 24 | except Exception: 25 | logger.exception("The email attribute does no exists falling back to OIDC Conformant.") 26 | email = self.userinfo.get("https://sso.mozilla.com/claim/emails")[0]["emails"] 27 | return email 28 | 29 | def apps(self, app_list): 30 | """Return a list of the apps a user is allowed to see in dashboard.""" 31 | authorized_apps = [] 32 | for app in app_list["apps"]: 33 | if not self._is_valid_yaml(app): 34 | try: 35 | logger.warning(f"invalid YAML for app {app.get("name", "unknown")} detected") 36 | except AttributeError: 37 | logger.warning(f"invalid YAML for app detected") 38 | continue 39 | if not self._is_authorized(app): 40 | continue 41 | authorized_apps.append(app) 42 | return authorized_apps 43 | 44 | @property 45 | def avatar(self): 46 | return None 47 | 48 | def group_membership(self): 49 | """Return list of group membership if user is asserted from ldap.""" 50 | if self.userinfo.get("https://sso.mozilla.com/claim/groups", []) != []: 51 | group_count = len(self.userinfo.get("https://sso.mozilla.com/claim/groups", [])) 52 | else: 53 | if self.userinfo.get("groups"): 54 | group_count = len(self.userinfo.get("groups", [])) 55 | else: 56 | group_count = 0 57 | 58 | if "https://sso.mozilla.com/claim/groups" in self.userinfo.keys() and group_count > 0: 59 | return self.userinfo["https://sso.mozilla.com/claim/groups"] 60 | 61 | if "groups" in self.userinfo.keys() and group_count > 0: 62 | return self.userinfo["groups"] 63 | else: 64 | # This could mean a user is authing with non-ldap 65 | return [] 66 | 67 | @property 68 | def first_name(self): 69 | """Return user first_name.""" 70 | return "" 71 | 72 | @property 73 | def last_name(self): 74 | """Return user last_name.""" 75 | return "" 76 | 77 | def user_identifiers(self): 78 | """Construct a list of potential user identifiers to match on.""" 79 | return [self.email(), self.userinfo["sub"]] 80 | 81 | def _is_authorized(self, app): 82 | if app["application"]["display"] == "False": 83 | return False 84 | elif not app["application"]["display"]: 85 | return False 86 | elif "everyone" in app["application"]["authorized_groups"]: 87 | return True 88 | elif set(app["application"]["authorized_groups"]) & set(self.group_membership()): 89 | return True 90 | elif set(app["application"]["authorized_users"]) & set(self.user_identifiers()): 91 | return True 92 | else: 93 | return False 94 | 95 | def _is_valid_yaml(self, app): 96 | """If an app doesn't have the required fields skip it.""" 97 | try: 98 | check_type(app["application"], Application) 99 | return True 100 | except TypeCheckError: 101 | return False 102 | 103 | 104 | class FakeUser(object): 105 | def __init__(self, app_config): 106 | """Constructor takes user session.""" 107 | self.app_config = app_config 108 | 109 | def email(self): 110 | return fake.email() 111 | 112 | def apps(self, app_list): 113 | authorized_apps = [] 114 | for app in app_list["apps"]: 115 | if not self._is_valid_yaml(app): 116 | continue 117 | if not self._is_authorized(app): 118 | continue 119 | authorized_apps.append(app) 120 | return authorized_apps 121 | 122 | @property 123 | def avatar(self): 124 | return self.profile["avatar"] 125 | 126 | def group_membership(self): 127 | return [] 128 | 129 | @property 130 | def first_name(self): 131 | return fake.first_name_male() 132 | 133 | @property 134 | def last_name(self): 135 | return fake.last_name() 136 | 137 | def _is_valid_yaml(self, app): 138 | return True 139 | 140 | def _is_authorized(self, app): 141 | if "everyone" in app["application"]["authorized_groups"]: 142 | return True 143 | else: 144 | return False 145 | -------------------------------------------------------------------------------- /dashboard/oidc_auth.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Optional 3 | import josepy.errors 4 | from josepy.jwk import JWK 5 | from josepy.jws import JWS 6 | 7 | # Class that governs all authentication with OpenID Connect. 8 | from flask_pyoidc import OIDCAuthentication # type: ignore 9 | from flask_pyoidc.provider_configuration import ClientMetadata # type: ignore 10 | from flask_pyoidc.provider_configuration import ProviderConfiguration # type: ignore 11 | 12 | KNOWN_ERROR_CODES = { 13 | "githubrequiremfa", 14 | "fxarequiremfa", 15 | "notingroup", 16 | "accesshasexpired", 17 | "primarynotverified", 18 | "incorrectaccount", 19 | "aai_failed", 20 | "staffmustuseldap", 21 | "maintenancemode", 22 | } 23 | 24 | 25 | class OpenIDConnect: 26 | """Auth object for login, logout, and response validation.""" 27 | 28 | def __init__(self, configuration): 29 | """Object initializer for auth object.""" 30 | self.oidc_config = configuration 31 | 32 | def client_info(self): 33 | client_info = ClientMetadata(client_id=self.oidc_config.client_id, client_secret=self.oidc_config.client_secret) 34 | return client_info 35 | 36 | def provider_info(self): 37 | auth_request_params = {"scope": ["openid", "profile", "email"]} 38 | provider_config = ProviderConfiguration( 39 | issuer=f"https://{self.oidc_config.OIDC_DOMAIN}", 40 | client_metadata=self.client_info(), 41 | auth_request_params=auth_request_params, 42 | ) 43 | return provider_config 44 | 45 | def get_oidc(self, app): 46 | provider_info = self.provider_info() 47 | o = OIDCAuthentication({"default": provider_info}, app) 48 | return o 49 | 50 | 51 | def friendly_connection_name(connection): 52 | CONNECTION_NAMES = { 53 | "google-oauth2": "Google", 54 | "github": "GitHub", 55 | "firefoxaccounts": "Mozilla Accounts", 56 | "Mozilla-LDAP-Dev": "LDAP", 57 | "Mozilla-LDAP": "LDAP", 58 | "email": "passwordless email", 59 | } 60 | return CONNECTION_NAMES.get(connection, connection) 61 | 62 | 63 | class TokenError(BaseException): 64 | pass 65 | 66 | 67 | class TokenVerification: 68 | def __init__(self, jws, public_key): 69 | try: 70 | self.jws = JWS.from_compact(jws) 71 | except josepy.errors.DeserializationError as exc: 72 | raise TokenError("Could not deserialize JWS") from exc 73 | except UnicodeDecodeError as exc: 74 | raise TokenError("Invalid encoding of JWS parts") from exc 75 | try: 76 | self.public_key = JWK.load(public_key) 77 | except josepy.errors.Error as exc: 78 | raise TokenError("Could not load public key") from exc 79 | if self.signed(): 80 | try: 81 | self.jws_data = json.loads(self.jws.payload) 82 | except json.decoder.JSONDecodeError as exc: 83 | raise TokenError("Invalid JSON in payload") from exc 84 | else: 85 | self.jws_data = { 86 | "code": "invalid", 87 | } 88 | 89 | @property 90 | def client(self) -> Optional[str]: 91 | return self.jws_data.get("client") 92 | 93 | @property 94 | def error_code(self) -> Optional[str]: 95 | return self.jws_data.get("code") 96 | 97 | @property 98 | def connection(self) -> Optional[str]: 99 | return self.jws_data.get("connection") 100 | 101 | @property 102 | def preferred_connection_name(self) -> Optional[str]: 103 | return self.jws_data.get("preferred_connection_name") 104 | 105 | def signed(self) -> bool: 106 | """ 107 | By the time we get here we've got a valid key, and a properly 108 | (probably) formatted JWS. 109 | 110 | The only thing left to do is to verify the signature on the JWS. 111 | """ 112 | try: 113 | return self.jws.verify(self.public_key) 114 | except josepy.errors.Error: 115 | return False 116 | 117 | def error_message(self) -> Optional[str]: 118 | """ 119 | If this isn't an error code we recognize then we _should_ log at a 120 | higher layer (one with more context). 121 | """ 122 | if self.client: 123 | client_name = self.client 124 | else: 125 | client_name = "this application" 126 | if self.connection: 127 | connection_name = friendly_connection_name(self.connection) 128 | else: 129 | connection_name = "Unknown" 130 | if self.preferred_connection_name: 131 | preferred_connection_name = friendly_connection_name(self.preferred_connection_name) 132 | else: 133 | preferred_connection_name = "Unknown" 134 | if self.error_code == "githubrequiremfa": 135 | return """ 136 | You must setup a security device ("MFA", "2FA") for your GitHub 137 | account in order to access this service. Please follow the 138 | GitHub documentation 139 | to setup your device, then try logging in again. 140 | """ 141 | if self.error_code == "fxarequiremfa": 142 | return """ 143 | Please 144 | secure your Mozilla Account with two-step authentication, 145 | then try logging in again.

146 | If you have just setup your security device and you see this 147 | message, please log out of 148 | Mozilla Accounts 149 | (click the "Sign out" button), then log back in. 150 | """ 151 | if self.error_code == "notingroup": 152 | return f""" 153 | Sorry, you do not have permission to access 154 | {client_name}. Please contact the application 155 | owner for access. If unsure who that may be, please contact 156 | ServiceDesk@mozilla.com for support. 157 | """ 158 | if self.error_code == "accesshasexpired": 159 | return f""" 160 | Sorry, your access to {client_name} has expired 161 | because you have not been actively using it. Please request 162 | access again. 163 | """ 164 | if self.error_code == "primarynotverified": 165 | return f""" 166 | You primary email address is not yet verified. Please verify your 167 | email address with 168 | {connection_name} 169 | in order to use this service. 170 | """ 171 | if self.error_code == "incorrectaccount": 172 | return f""" 173 | Sorry, you may not login using {connection_name}. Instead, 174 | please use {preferred_connection_name}. 175 | """ 176 | if self.error_code == "aai_failed": 177 | return f""" 178 | {client_name.title()} requires you to setup additional 179 | security measures for your account, such as enabling 180 | multi-factor authentication (MFA) or using a safer 181 | authentication method (such as a Mozilla Account login). You 182 | will not be able to login until this is done. 183 | """ 184 | if self.error_code == "staffmustuseldap": 185 | return """ 186 | It appears that you are attempting to log in with a Mozilla 187 | email address, which requires LDAP authentication. 188 | Please log out and sign back in using your LDAP credentials. 189 | To do this, click the Logout button below and then log in again 190 | by entering your email address and clicking Enter. 191 | Avoid using the buttons Sign in with Mozilla, with GitHub, or 192 | with Google. 193 | """ 194 | if self.error_code == "maintenancemode": 195 | return """ 196 | The system is in maintenance mode. Please try again later. 197 | """ 198 | return None 199 | -------------------------------------------------------------------------------- /dashboard/op/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/dashboard/op/__init__.py -------------------------------------------------------------------------------- /dashboard/op/yaml_loader.py: -------------------------------------------------------------------------------- 1 | """File based loader. Will fetch connected apps from yml file instead.""" 2 | 3 | import logging 4 | import yaml 5 | 6 | 7 | logger = logging.getLogger() 8 | 9 | 10 | class Application: 11 | def __init__(self, app_dict): 12 | self.app_dict = app_dict 13 | self.apps = self._load_data() 14 | self._alphabetize() 15 | 16 | def _load_data(self): 17 | try: 18 | stream = yaml.safe_load(self.app_dict) 19 | except yaml.YAMLError: 20 | logger.exception("Could not load YAML") 21 | stream = None 22 | return stream 23 | 24 | def _alphabetize(self): 25 | self.apps["apps"].sort(key=lambda a: a["application"]["name"].lower()) 26 | 27 | def vanity_urls(self): 28 | """ 29 | Parse apps.yml, return list of dicts, each dict is 30 | {'/some-redirect': 'https://some/destination'} 31 | """ 32 | redirects = [] 33 | try: 34 | all_apps = self.apps["apps"] 35 | except (TypeError, KeyError): 36 | return redirects 37 | for app_entry in all_apps: 38 | app = app_entry["application"] 39 | yaml_vanity_url_list = app.get("vanity_url") 40 | if not isinstance(yaml_vanity_url_list, list): 41 | continue 42 | for redirect in yaml_vanity_url_list: 43 | redirects.append({redirect: app["url"]}) 44 | return redirects 45 | -------------------------------------------------------------------------------- /dashboard/static/css/nlx.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; } 3 | 4 | form { 5 | margin: 0; } 6 | form * { 7 | font-family: inherit; } 8 | 9 | fieldset { 10 | border: 0; 11 | padding: 0; } 12 | 13 | label { 14 | margin: 2em 0; 15 | display: block; } 16 | 17 | input[type="text"], 18 | input[type="email"], 19 | input[type="password"] { 20 | font-size: 100%; 21 | background-color: #f4f4f4; 22 | border: 1px solid #fff; 23 | border-bottom: 1px solid #000; 24 | padding: .5em; 25 | display: block; 26 | width: 100%; 27 | margin-bottom: 1em; } 28 | input[type="text"]:hover, input[type="text"]:focus, 29 | input[type="email"]:hover, 30 | input[type="email"]:focus, 31 | input[type="password"]:hover, 32 | input[type="password"]:focus { 33 | border: 1px solid #229DC4; 34 | outline: none; } 35 | input[type="text"]:focus, 36 | input[type="email"]:focus, 37 | input[type="password"]:focus { 38 | background-color: #fff; } 39 | 40 | html, 41 | body { 42 | height: 100%; } 43 | 44 | body { 45 | background: #f4f4f4; 46 | color: #595959; 47 | font-family: "Open Sans", sans-serif; 48 | letter-spacing: 0.03em; 49 | margin: 0 1.5em; 50 | padding: 1em; 51 | display: grid; 52 | grid-template-rows: auto 1fr; } 53 | 54 | [hidden] { 55 | display: none; } 56 | 57 | [data-hidden] { 58 | position: absolute; 59 | top: -999em; 60 | opacity: 0; } 61 | 62 | h1 { 63 | margin: 0; } 64 | 65 | a { 66 | color: #229DC4; 67 | text-decoration: none; } 68 | a:hover { 69 | color: #000; 70 | text-decoration: underline; 71 | text-decoration-skip: ink; } 72 | 73 | p:first-child { 74 | margin-top: 0; } 75 | 76 | hr { 77 | border: none; 78 | height: 2px; 79 | background-color: #f4f4f4; 80 | margin: 2em 0; } 81 | 82 | .visually-hidden { 83 | position: absolute; 84 | left: -9999em; } 85 | 86 | .non-ldap-options-intro { 87 | padding-top: 2em; 88 | border-top: 2px solid #f4f4f4; 89 | margin-top: 2em; } 90 | 91 | .banner { 92 | background: #000; 93 | color: #fff; 94 | border-bottom: 1px solid #fff; 95 | padding: 1.5em; 96 | text-align: center; 97 | margin: -1em -2.5em 2em; 98 | display: flex; 99 | flex-direction: column; } 100 | .banner p { 101 | margin: 0; 102 | display: flex; 103 | flex-direction: column; 104 | align-items: center; } 105 | @media (min-width: 40em) { 106 | .banner p { 107 | display: block; } } 108 | .banner__button { 109 | background-color: rgba(255, 255, 255, 0.2); 110 | color: #fff; 111 | text-decoration: none; 112 | border-radius: 1.25em; 113 | font-weight: bold; 114 | padding: .25em 1em; 115 | margin: 1em 0 0 1em; 116 | text-transform: uppercase; } 117 | @media (min-width: 40em) { 118 | .banner__button { 119 | margin: .5em 0 .5em 1em; 120 | display: inline-block; 121 | vertical-align: middle; } } 122 | .banner__button:hover { 123 | color: #fff; 124 | text-decoration: none; } 125 | .banner__button:focus { 126 | outline: none; 127 | outline: 0; 128 | background-color: #f4f4f4; 129 | border-color: #f4f4f4; 130 | color: #229DC4; } 131 | 132 | .button { 133 | text-align: center; 134 | font-weight: bold; 135 | text-decoration: none; 136 | padding: .5em 2em; 137 | display: block; 138 | font-size: 1em; 139 | font-family: inherit; 140 | border-radius: 1.25em; 141 | background-color: #000; 142 | color: #fff; 143 | border: 1px solid transparent; 144 | text-transform: uppercase; 145 | transition: background-color .1s ease-in-out; 146 | -webkit-appearance: none; 147 | -moz-appearance: none; 148 | appearance: none; } 149 | .button:hover { 150 | background-color: #fff; 151 | color: #000; 152 | border-color: currentColor; 153 | text-decoration: none; } 154 | .button:focus { 155 | outline: none; 156 | outline: 0; 157 | background-color: #f4f4f4; 158 | border-color: #f4f4f4; 159 | color: #229DC4; } 160 | .button:active { 161 | background-color: #000; 162 | color: #fff; 163 | border-color: #000; } 164 | .button--full-width { 165 | width: 100%; } 166 | .button--secondary { 167 | border-color: #000; 168 | background-color: transparent; 169 | color: #000; 170 | text-transform: none; } 171 | .button--secondary:hover { 172 | background-color: #000; 173 | color: #fff; 174 | border-color: transparent; } 175 | .button--secondary:hover svg > path { 176 | fill: #fff; } 177 | .button--secondary:active { 178 | background-color: transparent; 179 | border-color: #000; 180 | color: #000; } 181 | .button--social { 182 | display: flex; 183 | padding: .5em 1em; } 184 | .button--social .icon, 185 | .button--social span { 186 | margin-right: auto; 187 | align-self: center; } 188 | .button--text-only { 189 | background-color: transparent; 190 | text-transform: none; 191 | border: 1px solid transparent; 192 | color: #229DC4; 193 | text-align: left; 194 | font-weight: normal; 195 | padding: .5em 1em; } 196 | .button--text-only:hover { 197 | background-color: transparent; 198 | color: #595959; 199 | border-color: transparent; 200 | text-decoration: underline; 201 | text-decoration-skip: ink; } 202 | .button--text-only:focus { 203 | outline: none; 204 | outline: 0; 205 | background-color: #f4f4f4; 206 | border-color: #f4f4f4; 207 | color: #229DC4; } 208 | .button--text-only:active { 209 | background-color: #000; 210 | color: #fff; 211 | border-color: #000; } 212 | .button--unpadded { 213 | margin-left: -1em; } 214 | .button:last-child { 215 | margin-bottom: 0; } 216 | .button svg, 217 | .button span { 218 | pointer-events: none; } 219 | 220 | .card { 221 | background-color: #fff; 222 | padding: 2.5em; 223 | position: relative; 224 | margin: auto 1em; 225 | box-shadow: 0.5em 0.5em 0 0 #e5e5e5; } 226 | @media (min-width: 20em) { 227 | .card { 228 | margin: auto; 229 | width: 100%; 230 | max-width: 26em; } } 231 | @supports (display: grid) { 232 | .card { 233 | grid-row-start: 2; } 234 | @media (min-height: 50em) { 235 | .card { 236 | top: -3em; 237 | /* compensate for negative margin for footer links */ } } } 238 | .card__back { 239 | margin-bottom: 1em; } 240 | .card__heading { 241 | font-size: 1.4em; 242 | font-weight: 400; 243 | text-transform: capitalize; 244 | padding-left: 2.125em; 245 | position: relative; 246 | min-height: 1.5em; } 247 | .card__heading svg { 248 | width: 1.5em; 249 | height: 1.5em; 250 | position: absolute; 251 | left: 0; 252 | top: 0; } 253 | .card__heading--success { 254 | color: #158640; } 255 | .card__heading--success svg use, 256 | .card__heading--success svg path { 257 | fill: #158640; } 258 | .card__heading--error { 259 | color: #ff4f5e; } 260 | .card__heading--error svg use, 261 | .card__heading--error svg path { 262 | fill: #ff4f5e; } 263 | .card [data-screen]:focus { 264 | outline: none; } 265 | 266 | .icon { 267 | display: inline-block; 268 | vertical-align: middle; 269 | width: 1em; 270 | height: 1em; 271 | margin-right: .5em; } 272 | 273 | .legal-links { 274 | position: absolute; 275 | bottom: -3em; 276 | left: 0; 277 | width: 100%; 278 | display: -webkit-box; 279 | display: -ms-flexbox; 280 | display: flex; 281 | -webkit-box-pack: start; 282 | -ms-flex-pack: start; 283 | justify-content: flex-start; } 284 | .legal-links li { 285 | margin: 0 1em 0 0; 286 | font-size: .75em; } 287 | .legal-links li:last-child { 288 | margin-left: auto; 289 | margin-right: 0; } 290 | .legal-links a { 291 | text-decoration: none; 292 | color: #000; 293 | border-radius: 1.25em; 294 | padding: .5em 1em; } 295 | .legal-links a:hover { 296 | color: #229DC4; 297 | text-decoration: underline; 298 | text-decoration-skip: ink; } 299 | .legal-links a:focus { 300 | outline: none; 301 | text-decoration: none; 302 | box-shadow: 0 0 0 1px #229DC4; } 303 | .legal-links a:active { 304 | box-shadow: none; } 305 | 306 | .list--plain { 307 | margin: 0; 308 | padding: 0; } 309 | .list--plain li { 310 | list-style: none; } 311 | 312 | .loading__spinner { 313 | display: block; 314 | margin: 3em auto; 315 | width: 2em; 316 | animation: rotateSpinner 1.2s linear infinite; 317 | transform-origin: center; } 318 | 319 | .loading__status { 320 | text-align: center; 321 | margin: 1em 0; } 322 | 323 | @keyframes rotateSpinner { 324 | 0% { 325 | transform: rotate(0deg); } 326 | 100% { 327 | transform: rotate(359deg); } } 328 | 329 | .login-options li { 330 | margin-bottom: 1em; } 331 | .login-options li:last-child { 332 | margin-bottom: 0; } 333 | 334 | .login-options + a { 335 | margin-top: 1em; } 336 | 337 | .logo { 338 | display: inline-block; 339 | margin-bottom: 2em; 340 | padding: 0; 341 | border-radius: 0; } 342 | .logo img { 343 | width: 100%; 344 | max-width: 6.5em; 345 | display: block; } 346 | 347 | .rp-name { 348 | margin-bottom: 1em; } 349 | .rp-name__text { 350 | font-size: .75em; } 351 | -------------------------------------------------------------------------------- /dashboard/static/fonts/opensans-bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/dashboard/static/fonts/opensans-bold.woff -------------------------------------------------------------------------------- /dashboard/static/fonts/opensans-bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/dashboard/static/fonts/opensans-bold.woff2 -------------------------------------------------------------------------------- /dashboard/static/fonts/opensans-bolditalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/dashboard/static/fonts/opensans-bolditalic.woff -------------------------------------------------------------------------------- /dashboard/static/fonts/opensans-bolditalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/dashboard/static/fonts/opensans-bolditalic.woff2 -------------------------------------------------------------------------------- /dashboard/static/fonts/opensans-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/dashboard/static/fonts/opensans-italic.woff -------------------------------------------------------------------------------- /dashboard/static/fonts/opensans-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/dashboard/static/fonts/opensans-italic.woff2 -------------------------------------------------------------------------------- /dashboard/static/fonts/opensans-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/dashboard/static/fonts/opensans-regular.woff -------------------------------------------------------------------------------- /dashboard/static/fonts/opensans-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/dashboard/static/fonts/opensans-regular.woff2 -------------------------------------------------------------------------------- /dashboard/static/img/404-birdcage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/dashboard/static/img/404-birdcage.jpg -------------------------------------------------------------------------------- /dashboard/static/img/auth0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/dashboard/static/img/auth0.png -------------------------------------------------------------------------------- /dashboard/static/img/email.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dashboard/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/dashboard/static/img/favicon.ico -------------------------------------------------------------------------------- /dashboard/static/img/feedback.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dashboard/static/img/github.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dashboard/static/img/legal.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dashboard/static/img/logout.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dashboard/static/img/mozilla-m.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /dashboard/static/img/mozilla.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /dashboard/static/img/privacy.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dashboard/static/img/request.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dashboard/static/img/search-w.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dashboard/static/img/search.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dashboard/static/img/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dashboard/static/img/success.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dashboard/static/js/base.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function(){ 2 | 'use strict'; 3 | 4 | // This is the js that powers the search box 5 | $(':input[name=filter]') 6 | .on('input', function() { 7 | // Get value just typed into textbox -- see .toLowerCase() 8 | var val = this.value.toLowerCase(); 9 | 10 | // Find all .user-profile divs 11 | $('#app-grid').find('.app-tile') 12 | // Find those that should be visible 13 | .filter(function() { 14 | return $(this).data('id').toLowerCase().indexOf( val ) > -1; 15 | }) 16 | // Make them visible 17 | .show() 18 | // Now go back and get only the visible ones 19 | .end().filter(':visible') 20 | // Filter only those for which typed value 'val' does not match the `data-id` value 21 | .filter(function() { 22 | return $(this).data('id').toLowerCase().indexOf( val ) === -1; 23 | }) 24 | // Fade those out 25 | .fadeOut(); 26 | }) 27 | .on('keypress', function (ev) { 28 | if (ev.key === 'Enter') { 29 | const tiles = $('#app-grid .app-tile:visible'); 30 | if (tiles.length === 1) { 31 | // If only one tile is visible, open its link on Enter 32 | window.open(tiles[0].href, '_blank'); 33 | } 34 | } 35 | }); 36 | 37 | // Search input: Highlight, Align, Focus 38 | var filter = $(':input[name=filter]'); 39 | $(filter).focus(); 40 | $('.filter .mui-textfield').addClass('yellow-border'); 41 | $('.filter img').addClass('yellow-border'); 42 | $('svg.clear').addClass('yellow-border'); 43 | $(filter).focusin(function() { 44 | $(filter).css('text-align', 'left'); 45 | }); 46 | $(filter).on('focusin mouseover', function() { 47 | $('.filter .mui-textfield').addClass('yellow-border'); 48 | $('.filter img').addClass('yellow-border'); 49 | $('svg.clear').addClass('yellow-border'); 50 | }); 51 | $(filter).mouseout(function() { 52 | var focus = $(filter).is(':focus'); 53 | if (!focus) { 54 | $('.filter .mui-textfield').removeClass('yellow-border'); 55 | $('.filter img').removeClass('yellow-border'); 56 | $('svg.clear').removeClass('yellow-border'); 57 | } 58 | }); 59 | $(filter).focusout(function() { 60 | $('.filter .mui-textfield').removeClass('yellow-border'); 61 | $('.filter img').removeClass('yellow-border'); 62 | $('svg.clear').removeClass('yellow-border'); 63 | if ($(filter).val() == '') { 64 | $(filter).css('text-align', 'center'); 65 | } else { 66 | $(filter).css('text-align', 'left'); 67 | } 68 | }); 69 | 70 | // Clear the search 71 | $('svg.clear').click(function() { 72 | $(filter).val(''); 73 | $('#app-grid').find('.app-tile').show(); 74 | $(filter).css('text-align', 'center'); 75 | }); 76 | 77 | // Search input mobile: Align 78 | var filter_mobile = $('.search-mobile :input[name=filter]'); 79 | $(filter_mobile).focusin(function() { 80 | $(filter_mobile).css('text-align', 'left'); 81 | }); 82 | $(filter_mobile).focusout(function() { 83 | if ($(filter_mobile).val() == '') { 84 | $(filter_mobile).css('text-align', 'center'); 85 | } else { 86 | $(filter_mobile).css('text-align', 'left'); 87 | } 88 | }); 89 | 90 | // Mobile search toggle 91 | $('.search-button button').click(function() { 92 | // Make sure user menu is hidden 93 | $('.user-menu').hide(); 94 | $('.menu').removeClass('enabled'); 95 | // Make sure we have the right logo and menu placement 96 | $('.logo-large').show(); 97 | $('.logo-small').addClass('mui--hidden-xs'); 98 | $('.mui-appbar').removeClass('menu-enabled'); 99 | $('.search-button').removeClass('menu-enabled'); 100 | // Show search input and invert button 101 | if ( $('.search-mobile').is(':visible')) { 102 | $('.search-mobile').fadeOut(); 103 | $('.search-button').removeClass('invert'); 104 | $('.search-button button:first').focus(); 105 | } 106 | else { 107 | $('.search-mobile').fadeIn(); 108 | $('.search-button').addClass('invert'); 109 | $('.search-mobile input:first').focus(); 110 | } 111 | }); 112 | 113 | // Toggle user menu 114 | $('.menu .menu-toggle').click(function() { 115 | if ( $('.menu').hasClass('enabled')) { 116 | $('.user-menu').hide(); 117 | $('.menu').removeClass('enabled'); 118 | } 119 | else { 120 | $('.user-menu').show(); 121 | $('.user-menu').attr('tabindex','0'); 122 | $('.user-menu').focus(); 123 | $('.menu').addClass('enabled'); 124 | } 125 | 126 | // If search-button is visible it's mobile viewport 127 | if ($('.search-button').is(':visible')) { 128 | // Make sure search input is hidden 129 | $('.search-mobile').hide(); 130 | $('.search-button').removeClass('invert'); 131 | // Toggle logo size and menu 132 | $('.logo-large').toggle(); 133 | $('.logo-small').toggleClass('mui--hidden-xs'); 134 | $('.mui-appbar').toggleClass('menu-enabled'); 135 | $('.search-button').toggleClass('menu-enabled'); 136 | } 137 | }); 138 | $('.content').click(function() { 139 | $('.user-menu').hide(); 140 | $('.menu').removeClass('enabled'); 141 | }); 142 | 143 | }); 144 | -------------------------------------------------------------------------------- /dashboard/static/js/ga.js: -------------------------------------------------------------------------------- 1 | /* global _dntEnabled */ 2 | 3 | if (!_dntEnabled()) { 4 | (function(w,d,s,l,i){ 5 | w[l]=w[l]||[];w[l].push({ 6 | 'gtm.start': new Date().getTime(), event:'gtm.js' 7 | }); 8 | var f=d.getElementsByTagName(s)[0]; 9 | var j=d.createElement(s); 10 | var dl=l!='dataLayer'?'&l='+l:''; 11 | j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl; 12 | f.parentNode.insertBefore(j,f); 13 | })(window,document,'script','dataLayer','GTM-TV8RRWS'); 14 | } 15 | -------------------------------------------------------------------------------- /dashboard/static/lib/dnt-helper/js/dnt-helper.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns true or false based on whether doNotTack is enabled. It also takes into account the 3 | * anomalies, such as !bugzilla 887703, which effect versions of Fx 31 and lower. It also handles 4 | * IE versions on Windows 7, 8 and 8.1, where the DNT implementation does not honor the spec. 5 | * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1217896 for more details 6 | * @params {string} [dnt] - An optional mock doNotTrack string to ease unit testing. 7 | * @params {string} [userAgent] - An optional mock userAgent string to ease unit testing. 8 | * @returns {boolean} true if enabled else false 9 | */ 10 | function _dntEnabled(dnt, userAgent) { 11 | 12 | 'use strict'; 13 | 14 | // for old version of IE we need to use the msDoNotTrack property of navigator 15 | // on newer versions, and newer platforms, this is doNotTrack but, on the window object 16 | // Safari also exposes the property on the window object. 17 | var dntStatus = dnt || navigator.doNotTrack || window.doNotTrack || navigator.msDoNotTrack; 18 | var ua = userAgent || navigator.userAgent; 19 | 20 | // List of Windows versions known to not implement DNT according to the standard. 21 | var anomalousWinVersions = ['Windows NT 6.1', 'Windows NT 6.2', 'Windows NT 6.3']; 22 | 23 | var fxMatch = ua.match(/Firefox\/(\d+)/); 24 | var ieRegEx = /MSIE|Trident/i; 25 | var isIE = ieRegEx.test(ua); 26 | // Matches from Windows up to the first occurance of ; un-greedily 27 | // http://www.regexr.com/3c2el 28 | var platform = ua.match(/Windows.+?(?=;)/g); 29 | 30 | // With old versions of IE, DNT did not exist so we simply return false; 31 | if (isIE && typeof Array.prototype.indexOf !== 'function') { 32 | return false; 33 | } else if (fxMatch && parseInt(fxMatch[1], 10) < 32) { 34 | // Can't say for sure if it is 1 or 0, due to Fx bug 887703 35 | dntStatus = 'Unspecified'; 36 | } else if (isIE && platform && anomalousWinVersions.indexOf(platform.toString()) !== -1) { 37 | // default is on, which does not honor the specification 38 | dntStatus = 'Unspecified'; 39 | } else { 40 | // sets dntStatus to Disabled or Enabled based on the value returned by the browser. 41 | // If dntStatus is undefined, it will be set to Unspecified 42 | dntStatus = { '0': 'Disabled', '1': 'Enabled' }[dntStatus] || 'Unspecified'; 43 | } 44 | 45 | return dntStatus === 'Enabled' ? true : false; 46 | } 47 | -------------------------------------------------------------------------------- /dashboard/static/lib/muicss/dist/js/mui.min.js: -------------------------------------------------------------------------------- 1 | !function r(s,a,l){function u(e,t){if(!a[e]){if(!s[e]){var i="function"==typeof require&&require;if(!t&&i)return i(e,!0);if(c)return c(e,!0);var n=new Error("Cannot find module '"+e+"'");throw n.code="MODULE_NOT_FOUND",n}var o=a[e]={exports:{}};s[e][0].call(o.exports,function(t){return u(s[e][1][t]||t)},o,o.exports,r,s,a,l)}return a[e].exports}for(var c="function"==typeof require&&require,t=0;t input","mui-textfield-inserted"],[".mui-textfield > textarea","mui-textfield-inserted"],[".mui-select > select","mui-select-inserted"],[".mui-select > select ~ .mui-event-trigger","mui-node-inserted"],[".mui-select > select:disabled ~ .mui-event-trigger","mui-node-disabled"]],i="",n=0,o=e.length;nr.clientHeight&&(i=parseInt(m.css(s,"padding-right"))+a,t.push("padding-right:"+i+"px")),r.scrollWidth>r.clientWidth&&(i=parseInt(m.css(s,"padding-bottom"))+a,t.push("padding-bottom:"+i+"px"))),e="."+p+"{",e+=t.join(" !important;")+" !important;}",u=h(e),m.on(o,"scroll",c,!0),l={left:m.scrollLeft(o),top:m.scrollTop(o)},m.addClass(s,p)}},log:function(){var e=window;if(o.debug&&void 0!==e.console)try{e.console.log.apply(e.console,arguments)}catch(t){var i=Array.prototype.slice.call(arguments);e.console.log(i.join("\n"))}},loadStyle:h,raiseError:function(t,e){if(!e)throw new Error("MUI: "+t);"undefined"!=typeof console&&console.warn("MUI Warning: "+t)},requestAnimationFrame:function(t){var e=window.requestAnimationFrame;e?e(t):setTimeout(t,0)},supportsPointerEvents:function(){if(void 0!==n)return n;var t=document.createElement("x");return t.style.cssText="pointer-events:auto",n="auto"===t.style.pointerEvents}}},{"../config":2,"./jqLite":6}],8:[function(t,e,i){"use strict";var s,a=t("./lib/util"),l=t("./lib/jqLite"),u="mui-overlay",c=/(iPad|iPhone|iPod)/g;function d(){var t,e=document.getElementById(u);if(e){for(;e.firstChild;)e.removeChild(e.firstChild);e.parentNode.removeChild(e),t=e.muiOptions.onclose,p(e)}return a.disableScrollLock(),m(),s&&s.focus(),t&&t(),e}function m(){l.off(document,"keyup",f)}function f(t){27===t.keyCode&&d()}function p(t){l.off(t,"click",h)}function h(t){t.target.id===u&&d()}e.exports=function(t){var e;if("on"===t){for(var i,n,o,r=arguments.length-1;0',e.appendChild(n),i=e._rippleEl=n.children[0],l.on(e,c,d)}var o,r,s=l.offset(e),a="touchstart"===t.type?t.touches[0]:t;r=2*(o=Math.sqrt(s.height*s.height+s.width*s.width))+"px",l.css(i,{width:r,height:r,top:Math.round(a.pageY-s.top-o)+"px",left:Math.round(a.pageX-s.left-o)+"px"}),l.removeClass(i,"mui--is-animating"),l.addClass(i,"mui--is-visible"),u.requestAnimationFrame(function(){l.addClass(i,"mui--is-animating")})}}}function d(t){var e=this._rippleEl;u.requestAnimationFrame(function(){l.removeClass(e,"mui--is-visible")})}e.exports={initListeners:function(){for(var t=document.getElementsByClassName("mui-btn"),e=t.length;e--;)s(t[e]);n.onAnimationStart("mui-btn-inserted",function(t){s(t.target)})}}},{"./lib/animationHelpers":4,"./lib/jqLite":6,"./lib/util":7}],10:[function(t,e,i){"use strict";var y=t("./lib/jqLite"),l=t("./lib/util"),n=t("./lib/animationHelpers"),u=t("./lib/forms"),C="mui--is-selected",E=document,c=window;function o(t){if(!0!==t._muiSelect&&(t._muiSelect=!0,!("ontouchstart"in E.documentElement))){var e=t.parentNode;if(!y.hasClass(e,"mui-select--use-default")){e._selectEl=t,e._menu=null,e._q="",e._qTimeout=null,t.disabled||(e.tabIndex=0),t.tabIndex=-1,y.on(t,"mousedown",r),y.on(e,"click",m),y.on(e,"blur focus",s),y.on(e,"keydown",a),y.on(e,"keypress",d);var i=document.createElement("div");i.className="mui-event-trigger",e.appendChild(i),y.on(i,n.animationEvents,function(t){var e=t.target.parentNode;t.stopPropagation(),"mui-node-disabled"===t.animationName?e.removeAttribute("tabIndex"):e.tabIndex=0})}}}function r(t){0===t.button&&t.preventDefault()}function s(t){l.dispatchEvent(this._selectEl,t.type,!1,!1)}function a(t){if(!t.defaultPrevented){var e=t.keyCode,i=this._menu;if(i){if(9===e)return i.destroy();27!==e&&40!==e&&38!==e&&13!==e||t.preventDefault(),27===e?i.destroy():40===e?i.increment():38===e?i.decrement():13===e&&(i.selectCurrent(),i.destroy())}else 32!==e&&38!==e&&40!==e||(t.preventDefault(),f(this))}}function d(t){var e=this._menu;if(!t.defaultPrevented&&e){var i=this;clearTimeout(this._qTimeout),this._q+=t.key,this._qTimeout=setTimeout(function(){i._q=""},300);var n,o=new RegExp("^"+this._q,"i"),r=e.itemArray;for(n in r)if(o.test(r[n].innerText)){e.selectPos(n);break}}}function m(t){0!==t.button||this._selectEl.disabled||(this.focus(),f(this))}function f(t){t._menu||(t._menu=new p(t,t._selectEl,function(){t._menu=null,t.focus()}))}function p(t,e,i){l.enableScrollLock(),this.itemArray=[],this.origPos=null,this.currentPos=null,this.selectEl=e,this.wrapperEl=t;var n=this._createMenuEl(t,e),o=this.menuEl=n[0],r=l.callback;this.onClickCB=r(this,"onClick"),this.destroyCB=r(this,"destroy"),this.wrapperCallbackFn=i,t.appendChild(this.menuEl);var s=u.getMenuPositionalCSS(t,o,n[1]);y.css(o,s),y.scrollTop(o,s.scrollTop);var a=this.destroyCB;y.on(o,"click",this.onClickCB),y.on(c,"resize",a),setTimeout(function(){y.on(E,"click",a)},0)}p.prototype._createMenuEl=function(t,e){var i,n,o,r,s,a,l,u,c=E.createElement("div"),d=e.children,m=this.itemArray,f=0,p=-1,h=0,v=0,g=0,b=document.createDocumentFragment();for(c.className="mui-select__menu",s=0,a=d.length;swindow.innerHeight&&(i.scrollTop=i.scrollTop+(n.top+n.height-window.innerHeight)+5)},p.prototype.destroy=function(){l.disableScrollLock(!0),y.off(this.menuEl,"click",this.clickCallbackFn),y.off(E,"click",this.destroyCB),y.off(c,"resize",this.destroyCB);var t=this.menuEl.parentNode;t&&(t.removeChild(this.menuEl),this.wrapperCallbackFn())},e.exports={initListeners:function(){for(var t=E.querySelectorAll(".mui-select > select"),e=t.length;e--;)o(t[e]);n.onAnimationStart("mui-select-inserted",function(t){o(t.target)})}}},{"./lib/animationHelpers":4,"./lib/forms":5,"./lib/jqLite":6,"./lib/util":7}],11:[function(t,e,i){"use strict";var f=t("./lib/jqLite"),p=t("./lib/util"),n=t("./lib/animationHelpers"),h="data-mui-controls",v="mui--is-active",g="mui.tabs.showstart",b="mui.tabs.showend",y="mui.tabs.hidestart",C="mui.tabs.hideend";function o(t){!0!==t._muiTabs&&(t._muiTabs=!0,f.on(t,"click",r))}function r(t){if(0===t.button){null===this.getAttribute("disabled")&&s(this)}}function s(t){var e,i,n,o,r,s,a,l,u,c=t.parentNode,d=t.getAttribute(h),m=document.getElementById(d);f.hasClass(c,v)||(m||p.raiseError('Tab pane "'+d+'" not found'),(i=function(t){var e,i=t.parentNode.children,n=i.length,o=null;for(;n--&&!o;)(e=i[n])!==t&&f.hasClass(e,v)&&(o=e);return o}(m))&&(n=i.id,u="["+h+'="'+n+'"]',o=document.querySelectorAll(u)[0],e=o.parentNode),r={paneId:d,relatedPaneId:n},s={paneId:n,relatedPaneId:d},a=p.dispatchEvent(o,y,!0,!0,s),l=p.dispatchEvent(t,g,!0,!0,r),setTimeout(function(){a.defaultPrevented||l.defaultPrevented||(e&&f.removeClass(e,v),i&&f.removeClass(i,v),f.addClass(c,v),f.addClass(m,v),p.dispatchEvent(o,C,!0,!1,s),p.dispatchEvent(t,b,!0,!1,r))},0))}e.exports={initListeners:function(){for(var t=document.querySelectorAll('[data-mui-toggle="tab"]'),e=t.length;e--;)o(t[e]);n.onAnimationStart("mui-tab-inserted",function(t){o(t.target)})},api:{activate:function(t){var e="["+h+"="+t+"]",i=document.querySelectorAll(e);i.length||p.raiseError('Tab control for pane "'+t+'" not found'),s(i[0])}}}},{"./lib/animationHelpers":4,"./lib/jqLite":6,"./lib/util":7}],12:[function(t,e,i){"use strict";var n=t("./lib/jqLite"),o=t("./lib/util"),r=t("./lib/animationHelpers"),s="mui--is-untouched",a="mui--is-pristine",l="mui--is-empty",u="mui--is-not-empty";function c(e){!0!==e._muiTextfield&&(e._muiTextfield=!0,e.value.length?n.addClass(e,u):n.addClass(e,l),n.addClass(e,s+" "+a),n.on(e,"blur",function t(){document.activeElement!==e&&(n.removeClass(e,s),n.addClass(e,"mui--is-touched"),n.off(e,"blur",t))}),n.one(e,"input change",function(){n.removeClass(e,a),n.addClass(e,"mui--is-dirty")}),n.on(e,"input change",d))}function d(){var t=this;t.value.length?(n.removeClass(t,l),n.addClass(t,u)):(n.removeClass(t,u),n.addClass(t,l))}e.exports={initialize:c,initListeners:function(){for(var t=document,e=t.querySelectorAll(".mui-textfield > input, .mui-textfield > textarea"),i=e.length;i--;)c(e[i]);r.onAnimationStart("mui-textfield-inserted",function(t){c(t.target)}),setTimeout(function(){var t=".mui-textfield.mui-textfield--float-label > label {"+["-webkit-transition","-moz-transition","-o-transition","transition",""].join(":all .15s ease-out;")+"}";o.loadStyle(t)},150),!1===o.supportsPointerEvents()&&n.on(t,"click",function(t){var e=t.target;if("LABEL"===e.tagName&&n.hasClass(e.parentNode,"mui-textfield--float-label")){var i=e.previousElementSibling;i&&i.focus()}})}}},{"./lib/animationHelpers":4,"./lib/jqLite":6,"./lib/util":7}]},{},[1]); -------------------------------------------------------------------------------- /dashboard/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block content_class %}error-page{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

404

9 | 10 |
11 |
12 |

Oye, something went wrong.

13 | Much like our bird, it looks like the page has flown the coop! 14 |
15 | 16 |
17 |
18 | Need Help? 19 | 20 | Return to dashboard 21 |
22 | 23 |
24 |
25 | 404 26 |
27 |
28 |
29 | 30 |
31 |
32 | 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /dashboard/templates/about.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block content %} 4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /dashboard/templates/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block search %} 4 |
5 | 6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 | {% endblock %} 20 | 21 | {% block search_button %} 22 |
23 | 24 |
25 | {% endblock %} 26 | 27 | {% block search_mobile %} 28 |
29 |
30 | 31 | 32 |
33 |
34 | {% endblock %} 35 | 36 | {% block menu %} 37 | 56 | {% endblock %} 57 | 58 | {% block user_menu %} 59 | 64 | {% endblock %} 65 | 66 | {% block content %} 67 |
68 | 80 |
81 | {% endblock %} 82 | 83 | 91 | -------------------------------------------------------------------------------- /dashboard/templates/forbidden.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block content_class %}error-page forbidden{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Error

9 | 10 |
11 |
12 |

13 | {%- if message %} 14 | {{- message|safe }} 15 | {%- else %} 16 | Oye, something went wrong. 17 | {%- endif %} 18 |

19 |
20 |
21 |
22 | 23 |
24 |
25 | Logout 26 | Need Help? 27 | Return to dashboard 28 |
29 |
30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /dashboard/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block content %} 4 |

You are not signed in.

5 |

Please sign in.

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /dashboard/templates/icons/arrow-right.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dashboard/templates/icons/info.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dashboard/templates/icons/loading-black.svg: -------------------------------------------------------------------------------- 1 | 2 | Loading… 3 | 4 | 5 | 6 | 7 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /dashboard/templates/icons/loading-white.svg: -------------------------------------------------------------------------------- 1 | 2 | Loading… 3 | 4 | 5 | 6 | 7 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /dashboard/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Mozilla SSO Dashboard 5 | 6 | 7 | 8 | 9 | {% assets "css_all" %} 10 | 11 | {% endassets %} 12 | 13 | 14 | 15 | 16 | {% block appbar %} 17 |
18 |
19 | 27 |
28 |
29 | {% block search %}{% endblock %} 30 |
31 |
32 | {% block menu %}{% endblock %} 33 | {% block search_button %}{% endblock %} 34 |
35 |
36 | {% endblock %} 37 | 38 | {% block search_mobile %}{% endblock %} 39 | 40 | {% block messages %}{% endblock %} 41 | 42 |
43 |
44 | {% block content %} 45 | {% endblock %} 46 |
47 |
48 | 49 | {% block user_menu %}{% endblock %} 50 | 51 | {% block footer %} 52 |
53 |
54 |
55 |
56 | 57 | Need Help? 58 |
59 |
60 |
61 |
62 | 63 | Feedback 64 |
65 |
66 |
67 |
68 | 69 | Contribute 70 |
71 |
72 |
73 |
74 | 75 | Legal 76 |
77 |
78 |
79 |
80 | 81 | Privacy 82 |
83 |
84 |
85 |
86 | {% endblock %} 87 | 88 | 89 | {% assets "js_all" %} 90 | 91 | {% endassets %} 92 | {% block extra_js %}{% endblock %} 93 | 94 | 95 | -------------------------------------------------------------------------------- /dashboard/templates/signout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Mozilla Login 5 | 6 | 7 | 8 | 9 | 10 |
11 | 14 | 15 |

16 | 17 | Log out success 18 |

19 |

Make sure you also log out of each individual application used this session.

20 |
21 | Log in to Mozilla 22 | 27 |
28 | 29 | 30 | -------------------------------------------------------------------------------- /dashboard/vanity.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from flask import make_response 3 | from flask import redirect 4 | from flask import request 5 | 6 | from dashboard.op import yaml_loader 7 | 8 | logger = logging.getLogger() 9 | 10 | 11 | class Router(object): 12 | def __init__(self, app, app_list): 13 | self.app = app 14 | self.url_list = yaml_loader.Application(app_list.apps_yml).vanity_urls() 15 | 16 | def setup(self): 17 | for url in self.url_list: 18 | for vanity_url in url.keys(): 19 | try: 20 | self.app.add_url_rule(vanity_url, vanity_url, self.redirect_url) 21 | self.app.add_url_rule(vanity_url + "/", vanity_url + "/", self.redirect_url) 22 | except Exception: 23 | logger.exception(f"Could not add {vanity_url}") 24 | 25 | def redirect_url(self): 26 | vanity_url = "/" + request.url.split("/")[3] 27 | 28 | for match in self.url_list: 29 | for key in match.keys(): 30 | if key == vanity_url: 31 | resp = make_response(redirect(match[vanity_url], code=301)) 32 | resp.headers["Cache-Control"] = ( 33 | "no-store, no-cache, must-revalidate, " "post-check=0, pre-check=0, max-age=0" 34 | ) 35 | resp.headers["Expires"] = "-1" 36 | return resp 37 | else: 38 | pass 39 | -------------------------------------------------------------------------------- /docs/architecture.mermaid: -------------------------------------------------------------------------------- 1 | graph TD 2 | 3 | %% Define entites 4 | user((fa:fa-user SSO Dashboard User)) 5 | idp([Auth0]) 6 | gcpLoadBalancer([GCP Load Balancer]) 7 | subgraph cloudrun [GCP Cloud Run Environment] 8 | gcpCloudRunContainer1[[Container 1]] 9 | gcpCloudRunContainer2[[Container 2]] 10 | gcpCloudRunContainer3[[Container 3]] 11 | end 12 | subgraph CloudFrontCDN [AWS] 13 | awsCDN([cdn.sso.mozilla.com]) 14 | appImages(img, css, js) 15 | appAppsYaml(apps.yml) 16 | end 17 | 18 | gcpSecretsManager[(fa:fa-database GCP Secrets Manager)] 19 | appSecrets(Application Secrets) 20 | 21 | %% Define Flows 22 | user <--> idp 23 | 24 | user --> 25 | gcpLoadBalancer --> 26 | cloudrun --> 27 | CloudFrontCDN 28 | 29 | awsCDN --> appImages 30 | awsCDN --> appAppsYaml 31 | 32 | cloudrun --> 33 | gcpSecretsManager --> 34 | appSecrets 35 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development Guide 2 | 3 | ## Getting Started 4 | 5 | In order to develop for the sso-dashboard and run the working dashboard you must install Docker desktop and have a copy of a working envfile. 6 | 7 | Make sure that everything is working prior to feature development by running: 8 | 9 | `docker compose up` 10 | 11 | 12 | On successful boot of the docker container your shell should say: 13 | 14 | ```shell 15 | 16 | Status: Downloaded newer image for 320464205386.dkr.ecr.us-west-2.amazonaws.com/sso-dashboard:16fc657835c5bf7b5a83d1f7a686539507ee94c6 17 | * Running on http://0.0.0.0:8000/ (Press CTRL+C to quit) 18 | * Restarting with inotify reloader 19 | * Debugger is active! 20 | * Debugger PIN: 125-948-166 21 | 22 | ``` 23 | 24 | From this point the dashboard is running in python-flask live debug mode with the autoreloader on save. The debugger pin can be used for interactive debugging via the web shell. 25 | 26 | 27 | ## Development Standards 28 | 29 | * Use pep8 conventions as they make sense. 30 | * Black formatter with `120` line length wraps. 31 | * Cover new code with adequate test coverage > 80% 32 | 33 | ## Running the test suite 34 | 35 | Tests are written in py.test. They can be run via: 36 | 37 | `make test STAGE=dev` 38 | 39 | ## Releasing the Dashboard 40 | 41 | In the Mozilla IAM account there is a CI/CD pipeline that will release the dev dashboard on merge to master. For production releases PR master to the _production_ branch. 42 | 43 | In the event that CI/CD is broken you may manually build the dashboard and release it using the `Makefile`. The primary differences between the stage and dev releases are the CLUSTER that the image deployed to using Kubernetes Helm Charts. 44 | 45 | ```bash 46 | - make login CLUSTER_NAME=${CLUSTER_NAME} 47 | - make build COMMIT_SHA=${CODEBUILD_RESOLVED_SOURCE_VERSION} 48 | - make push DOCKER_DEST=${DOCKER_REPO}:${CODEBUILD_RESOLVED_SOURCE_VERSION} 49 | - make release STAGE=${DEPLOY_ENV} 50 | ``` 51 | 52 | ## Debugging a Failed Release 53 | 54 | In order to debug a failed release you will need access to Graylog at `https://graylog.infra.iam.mozilla.com/search` stdout and stderr are shipped there. 55 | -------------------------------------------------------------------------------- /docs/images/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/docs/images/architecture.png -------------------------------------------------------------------------------- /docs/images/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/docs/images/dashboard.png -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | ### This file is a sample environment file for use with the project. 2 | # To see these in action see the `clouddeploy` directory. 3 | 4 | # This should be random in production deployment used in session security. 5 | # Easy way to generate: 6 | # openssl rand -hex 64 7 | SECRET_KEY="this is a secret key" 8 | 9 | # local, development, staging, or production. 10 | ENVIRONMENT="local" 11 | 12 | # OpenID Connect Specific Parameters 13 | # We use Auth0, the configs for this would be under the Application "Settings" 14 | # page. 15 | OIDC_DOMAIN="auth0.example.com" 16 | OIDC_CLIENT_ID="" 17 | OIDC_CLIENT_SECRET="" 18 | 19 | # Yes, this one is not namespaced. 20 | # DEBT(bhee): standardize at some point. 21 | OIDC_REDIRECT_URI='https://localhost.localdomain:8000/redirect_uri' 22 | 23 | # Controls the logging levels. 24 | FLASK_DEBUG=True 25 | 26 | # Unused right now. 27 | TESTING=False 28 | 29 | # Reasonable for local development, you'll want to change these in production 30 | # though. 31 | CSRF_ENABLED=True 32 | PERMANENT_SESSION=True 33 | PERMANENT_SESSION_LIFETIME=86400 34 | SESSION_COOKIE_HTTPONLY=True 35 | SERVER_NAME=localhost.localdomain:8000 36 | PREFERRED_URL_SCHEME=http 37 | 38 | # You'll need a running redis. 39 | # See compose.yml. 40 | REDIS_CONNECTOR=redis:6379 41 | 42 | # Where we publish the `apps.yml` file. 43 | CDN=https://cdn.sso.mozilla.com 44 | S3_BUCKET=sso-dashboard.configuration 45 | 46 | # Used to decode the JWS from Auth0 (e.g. for if we redirect back and there's some 47 | # context Auth0 passes us, like the error code.) 48 | # Base64 + PEM encoded. 49 | FORBIDDEN_PAGE_PUBLIC_KEY="" 50 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import path from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | import js from "@eslint/js"; 5 | import { FlatCompat } from "@eslint/eslintrc"; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | const compat = new FlatCompat({ 10 | baseDirectory: __dirname, 11 | recommendedConfig: js.configs.recommended, 12 | allConfig: js.configs.all 13 | }); 14 | 15 | export default [...compat.extends("eslint:recommended"), { 16 | languageOptions: { 17 | globals: { 18 | ...globals.browser, 19 | ...globals.jquery, 20 | }, 21 | }, 22 | 23 | rules: { 24 | indent: ["error", 4], 25 | "linebreak-style": ["error", "unix"], 26 | quotes: ["error", "single"], 27 | semi: ["error", "always"], 28 | curly: ["error", "all"], 29 | "one-var-declaration-per-line": ["error", "always"], 30 | "new-cap": "error", 31 | }, 32 | }]; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sso-dashboard", 3 | "version": "0.1.0", 4 | "description": "Mozilla SSO dashboard", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/mozilla-iam/sso-dashboard" 8 | }, 9 | "author": "Mozilla", 10 | "license": "MPLv2", 11 | "bugs": { 12 | "url": "https://github.com/mozilla-iam/sso-dashboard/issues" 13 | }, 14 | "scripts": { 15 | "lint:js": "eslint 'dashboard/static/js/*.js'", 16 | "lint:css": "stylelint 'dashboard/static/css/*.scss'", 17 | "update:static": "node 'tools/copy-static-files.js'" 18 | }, 19 | "engines": { 20 | "node": "v18.20.4" 21 | }, 22 | "devDependencies": { 23 | "eslint": "^9.9.0", 24 | "stylelint": "^16.10.0", 25 | "stylelint-config-standard-scss": "^13.1.0" 26 | }, 27 | "dependencies": { 28 | "jquery": "^3.7.1", 29 | "muicss": "^0.10.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | include = '\.pyi?$' 4 | exclude = ''' 5 | /( 6 | \.git 7 | | \.hg 8 | | \.mypy_cache 9 | | \.tox 10 | | \.venv 11 | | _build 12 | | python-modules/.cis-environment/ 13 | | build 14 | | dist 15 | | cloudformation 16 | | docker 17 | | ci 18 | | docs 19 | )/ 20 | ''' 21 | 22 | [tool.pytest.ini_options] 23 | addopts = "--cov dashboard" 24 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | annotated-types==0.7.0 2 | Beaker==1.13.0 3 | cachetools==5.4.0 4 | certifi==2024.7.4 5 | cffi==1.16.0 6 | cfgv==3.4.0 7 | chardet==5.2.0 8 | charset-normalizer==3.3.2 9 | click==8.1.7 10 | colorama==0.4.6 11 | cryptography==44.0.1 12 | cssmin==0.2.0 13 | defusedxml==0.7.1 14 | distlib==0.3.8 15 | Faker==26.1.0 16 | filelock==3.15.4 17 | Flask==3.0.3 18 | Flask-Assets==2.1.0 19 | Flask-Session==0.8.0 20 | Flask-pyoidc==3.14.3 21 | flask-talisman==1.1.0 22 | future==1.0.0 23 | gevent==24.2.1 24 | greenlet==3.0.3 25 | gunicorn==23.0.0 26 | identify==2.6.0 27 | idna==3.7 28 | importlib_resources==6.4.0 29 | itsdangerous==2.2.0 30 | Jinja2==3.1.6 31 | josepy==1.14.0 32 | jsmin==3.0.1 33 | Mako==1.3.5 34 | MarkupSafe==2.1.5 35 | nodeenv==1.9.1 36 | oic==1.6.1 37 | packaging==24.1 38 | platformdirs==4.2.2 39 | pluggy==1.5.0 40 | pre-commit==3.8.0 41 | pycparser==2.22 42 | pycryptodomex==3.20.0 43 | pydantic==2.8.2 44 | pydantic-settings==2.4.0 45 | pydantic_core==2.20.1 46 | pyjwkest==1.4.2 47 | pyOpenSSL==25.0.0 48 | pyproject-api==1.7.1 49 | python-dateutil==2.9.0.post0 50 | python-dotenv==1.0.1 51 | PyYAML==6.0.1 52 | redis==5.2.0 53 | requests==2.32.4 54 | setuptools==78.1.1 55 | six==1.16.0 56 | tox==4.16.0 57 | types-PyYAML==6.0.12.20240724 58 | typing_extensions==4.12.2 59 | urllib3==2.2.2 60 | virtualenv==20.26.6 61 | webassets==2.0 62 | Werkzeug==3.0.6 63 | zope.event==5.0 64 | zope.interface==6.4.post2 65 | typeguard==4.4.2 66 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore=E731 3 | max-line-length=120 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup, find_packages 5 | 6 | with open("README.md", "r") as fh: 7 | long_description = fh.read() 8 | 9 | requirements = [] 10 | 11 | setup_requirements = ["pytest-runner", "credstash", "everett", "josepy", "flask_pyoidc"] 12 | 13 | test_requirements = ["pytest", "pytest-watch", "pytest-cov", "faker"] 14 | 15 | setup( 16 | name="dashboard", 17 | version="0.0.1", 18 | author="Andrew Krug", 19 | author_email="akrug@mozilla.com", 20 | description="A single signon dashboard for mozilla-iam.", 21 | long_description=long_description, 22 | url="https://github.com/mozilla-iam/sso-dashboard", 23 | classifiers=[ 24 | "Programming Language :: Python :: 3", 25 | "License :: OSI Approved :: Mozilla Public License", 26 | "Operating System :: OS Independent", 27 | ], 28 | install_requires=requirements, 29 | license="Mozilla Public License 2.0", 30 | include_package_data=True, 31 | packages=find_packages(), 32 | setup_requires=setup_requirements, 33 | test_suite="tests", 34 | tests_require=test_requirements, 35 | zip_safe=False, 36 | ) 37 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/tests/__init__.py -------------------------------------------------------------------------------- /tests/data/apps.yml: -------------------------------------------------------------------------------- 1 | # Copied a snapshot from https://github.com/mozilla-iam/sso-dashboard-configuration/ 2 | apps: 3 | - application: 4 | authorized_groups: 5 | - mozilliansorg_netlify-access 6 | authorized_users: [] 7 | client_id: client-id-for-netlify 8 | display: true 9 | logo: netlify.png 10 | name: Netlify 11 | op: auth0 12 | url: https://some-url-for-netlify 13 | vanity_url: 14 | - /netlify 15 | - application: 16 | authorized_groups: 17 | - team_moco 18 | - team_mofo 19 | - team_mzla 20 | authorized_users: [] 21 | display: true 22 | logo: accountmanager.png 23 | name: Account Portal 24 | op: auth0 25 | url: https://some-url-for-account-manager 26 | vanity_url: 27 | - /accountmanager 28 | - application: 29 | authorized_groups: 30 | - mozilliansorg_acoustic_production_access 31 | authorized_users: [] 32 | client_id: client-id-for-acoustic 33 | display: true 34 | logo: acoustic.png 35 | name: Acoustic 36 | op: auth0 37 | url: https://some-url-for-acoustic 38 | vanity_url: 39 | - /acoustic 40 | -------------------------------------------------------------------------------- /tests/data/mfa-required-jwt: -------------------------------------------------------------------------------- 1 | eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJjb2RlIjoiZ2l0aHVicmVxdWlyZW1mYSIsInJlZGlyZWN0X3VyaSI6Imh0dHBzOi8vc3NvLm1vemlsbGEuY29tL3JlZGlyZWN0X3VyaSIsImNsaWVudCI6InNzby5tb3ppbGxhLmNvbSIsImNvbm5lY3Rpb24iOiJnaXRodWIiLCJwcmVmZXJyZWRfY29ubmVjdGlvbl9uYW1lIjoiIiwiaWF0IjoxNTIxMjIxMTY5LCJleHAiOjE1MjEyMjQ3OTl9.gXAxDWof0wkxhTfGoCZ6YsuJhwjGDLy9CSxjdm8peSPSEZtaSx3F9mLpjh0RMCzzBZjgR14CWWRrn-RbiX_FjRzKTHoGT9EgmyysOQpRB7_1Jwv_z_Ji771BNSS4bJMES78Hzyb4PPhoMjla1nu6ob8m2OE26jF7kDAD07k130uQwA-QtWTez5ktpjrbH3wkVp-v7Z06ZwdVhRO512XeExL9cd5wrDj7N7ae0nxEuRCWcF6NYdCIwcFcav1MB8hPnFuaM470Sa4wzFPeNRdBAOYLAgDTfYQhJOcUcmM2l-f1mUcl5feAVbF0IswpOLO7O9IVGhMJnLjHdMYxYddQJ07ZENPaHAJT9X-NcdDOlQh2Fi8XPaVmaSFjvZIGpATScW_9fdrId45bpcLVdkPYM1Rwf2qOkSSwcCRUmH-dzWMwBCHplruuA_O1LDeWxxVkYt2IXT5b8fz60vfEfGmYzo6g1oBpn6pjJCzjQjWWrWIH_RrUOl1lqjpviXmVPCIGyJzYfD7Nwl2d6BaBPMKaKNaBrwsbaxgeO8iyYNirDz74XAije_vk8NFW8Xy_MLGB3ZzPCeZhqBYiRjRlZVi2nonpswyQyV3KfYcPFeuywHf0Y4M9R3O6dXmkH3Fv83WLxphHDPvNMEJ7WwKD8S4tHYfnvxSEicCiGBKlUKbTgIs 2 | -------------------------------------------------------------------------------- /tests/data/public-signing-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAq+VUxlYiD5AuhhwkHDoy 3 | aWWF3pYsBlFh9TzwYxcMGo6LWT89coLnP7VTyKlglNIfLnJd7jca9UBuB8HTPPaj 4 | NHnNTiFyPFSn1hiXjH2byCC6p5fprEaDk5waZU580i4CiYXqkkYgUmuH5ou2yx5n 5 | VBTbfr+rdT4kKQv/CzOYGZ7+NMUGWa3/5tLfIrEvgWkSLInzkUeXTKxnE+9kP/Sa 6 | C8H6l6pJnoh9ZbcrxDhdmYzLLvsKHCkbvgBs3biAdHCsxqDxW1HiN32XxG8cZrsM 7 | 5ud7glsMceYZO6hCSen/rATZbdstDMcKkf310iPX1Eayg3Op3YRUSvLsVjynlPff 8 | TRV1fChIiqpAggKlx1wjEI6QPnAgiSQ4XBpy1B+qTI9mwPapNrcb3nJE41MOGDdl 9 | aKpyLxgZxB86cJa9UAvFHANGO3Ep5VgtR3hQ+jZF6DaGQ8c0srhx8178ubrlV64k 10 | uq+Z3UFAdxClvSFw4x5rNmmWgS7o48or2xVuuW1414FXMPok+C5GZlgzdnsgw1ZT 11 | hsMcetmmzkaQ9rylfauQGX32Negqe9arGEFmpjb2Tol4NMQ//734nTSvCYf/J4B/ 12 | tt62s9tAMMYvJo7FQWj4BK7uF/blSm73aEoVQb4wsZ2QYjLyYVphvPZkaWZdp4Z/ 13 | x4I9OOyNa8mhfd+xt9OnYusCAwEAAQ== 14 | -----END PUBLIC KEY----- 15 | -------------------------------------------------------------------------------- /tests/data/userinfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "aaaaaaaaa", 3 | "id_token": { 4 | "aud": [ 5 | "aaaaaaaaaaaaaaa" 6 | ], 7 | "exp": 1503327981, 8 | "https://sso.mozilla.com/claim/_HRData": { 9 | "placeholder": "empty" 10 | }, 11 | "https://sso.mozilla.com/claim/emails": [ 12 | "akrug@mozilla.com" 13 | ], 14 | "https://sso.mozilla.com/claim/groups": [ 15 | "IntranetWiki", 16 | "StatsDashboard", 17 | "phonebook_access", 18 | "team_moco", 19 | "cloudhealth-enhanced-standard-user", 20 | "mozilliansorg_cis_whitelist" 21 | ], 22 | "iat": 1502723181, 23 | "iss": "https://auth.mozilla.auth0.com/", 24 | "nonce": "1234567890", 25 | "sub": "ad|Mozilla-LDAP|akrug" 26 | }, 27 | "userinfo": { 28 | "_HRData": { 29 | "placeholder": "empty" 30 | }, 31 | "clientID": "abc123", 32 | "created_at": "2017-02-06T22:53:30.473Z", 33 | "email": "akrug@mozilla.com", 34 | "email_verified": true, 35 | "emails": [ 36 | "akrug@mozilla.com" 37 | ], 38 | "family_name": "Krug", 39 | "given_name": "Andrew", 40 | "groups": [ 41 | "IntranetWiki", 42 | "StatsDashboard", 43 | "phonebook_access", 44 | "team_moco", 45 | "cloudhealth-enhanced-standard-user", 46 | "mozilliansorg_cis_whitelist" 47 | ], 48 | "https://sso.mozilla.com/claim/_HRData": { 49 | "placeholder": "empty" 50 | }, 51 | "https://sso.mozilla.com/claim/emails": [ 52 | "akrug@mozilla.com" 53 | ], 54 | "https://sso.mozilla.com/claim/groups": [ 55 | "IntranetWiki", 56 | "StatsDashboard", 57 | "phonebook_access", 58 | "team_moco", 59 | "cloudhealth-enhanced-standard-user", 60 | "mozilliansorg_cis_whitelist" 61 | ], 62 | "identities": [ 63 | { 64 | "connection": "Mozilla-LDAP", 65 | "isSocial": false, 66 | "provider": "ad", 67 | "user_id": "Mozilla-LDAP|akrug" 68 | } 69 | ], 70 | "multifactor": [ 71 | "duo" 72 | ], 73 | "name": "Andrew Krug", 74 | "nickname": "Andrew Krug", 75 | "picture": "https://s.gravatar.com/avatar/abf4796da7aa08d55d5facd12be2c2b0?s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fak.png", 76 | "sub": "ad|Mozilla-LDAP|akrug", 77 | "updated_at": "2017-08-14T15:06:21.078Z", 78 | "user_id": "ad|Mozilla-LDAP|akrug" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/tests/models/__init__.py -------------------------------------------------------------------------------- /tests/models/test_tile.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import os 3 | import urllib3 4 | from unittest import mock 5 | from dashboard.models.tile import __file__ as module_file 6 | from dashboard.models.tile import CDNTransfer 7 | 8 | 9 | class MockAppConfig: 10 | CDN = "http://mock-cdn.com" 11 | 12 | 13 | @pytest.fixture 14 | def cdn_transfer(): 15 | app_config = MockAppConfig() 16 | return CDNTransfer(app_config) 17 | 18 | 19 | def test_is_updated_etag_mismatch(mocker, cdn_transfer): 20 | mock_request = mocker.patch("urllib3.PoolManager.request") 21 | mock_request.return_value.headers = {"ETag": "new-etag"} 22 | mocker.patch.object(cdn_transfer, "_etag", return_value="old-etag") 23 | 24 | assert cdn_transfer.is_updated() is True 25 | 26 | 27 | def test_is_updated_etag_match(mocker, cdn_transfer): 28 | mock_request = mocker.patch("urllib3.PoolManager.request") 29 | mock_request.return_value.headers = {"ETag": "matching-etag"} 30 | mocker.patch.object(cdn_transfer, "_etag", return_value="matching-etag") 31 | 32 | assert cdn_transfer.is_updated() is False 33 | 34 | 35 | def test_update_etag(mocker, cdn_transfer): 36 | mock_open = mocker.patch("builtins.open", mocker.mock_open()) 37 | 38 | cdn_transfer._update_etag("new-etag") 39 | 40 | mock_open.assert_called_once_with(os.path.join(os.path.dirname(module_file), "../data/apps.yml-etag"), "w+") 41 | mock_open().write.assert_called_once_with("new-etag") 42 | 43 | 44 | def test_etag_file_exists(mocker, cdn_transfer): 45 | mock_open = mocker.patch("builtins.open", mocker.mock_open(read_data="stored-etag")) 46 | 47 | assert cdn_transfer._etag() == "stored-etag" 48 | mock_open.assert_called_once_with(os.path.join(os.path.dirname(module_file), "../data/apps.yml-etag"), "r") 49 | 50 | 51 | def test_etag_file_missing(mocker, cdn_transfer): 52 | mock_open = mocker.patch("builtins.open", mocker.mock_open()) 53 | mock_open.side_effect = FileNotFoundError 54 | 55 | assert cdn_transfer._etag() == "12345678" 56 | 57 | 58 | # NOTE: Temporarily disabling this test until we have a reliable means of patching out 59 | # `urllib3` calls. Even after multiple attempts, the call to `request` still 60 | # tries to resolve the mock domain in the mocked CDN object. 61 | # References: 62 | # - https://mozilla-hub.atlassian.net/jira/software/c/projects/IAM/issues/IAM-1403 63 | # 64 | @pytest.mark.skip(reason="Cannot properly mock `urllib3`'s `request` call.") 65 | def test_download_config(mocker, cdn_transfer): 66 | mock_response = mock.Mock() 67 | mock_response.status = 200 68 | mock_response.headers = {"ETag": "new-etag"} 69 | mock_response.data = b"mock apps.yml content" 70 | with mock.patch("urllib3.PoolManager") as mock_pool_manager, mock.patch("os.fsync") as mock_fsync, mock.patch( 71 | "builtins.open", mock.mock_open() 72 | ) as mock_open_yml, mock.patch("builtins.open", mock.mock_open()) as mock_open_etag: 73 | mock_http = mock_pool_manager.return_value 74 | mock_http.request.return_value = mock_response 75 | mock_open_yml.return_value.fileno.return_value = 3 76 | cdn_transfer._download_config() 77 | 78 | mock_open_yml.assert_any_call(os.path.join(os.path.dirname(module_file), "../data/apps.yml"), "wb") 79 | mock_open_yml().write.assert_called_once_with(b"mock apps.yml content") 80 | mock_fsync().assert_called_once_with(3) 81 | mock_open_etag.assert_any_call(os.path.join(os.path.dirname(module_file), "../data/apps.yml-etag"), "w+") 82 | mock_open_etag().write.assert_called_once_with("new-etag") 83 | 84 | 85 | def test_download_config_http_error(mocker, cdn_transfer): 86 | mocker.patch("urllib3.PoolManager.request", side_effect=urllib3.exceptions.HTTPError) 87 | 88 | with pytest.raises(urllib3.exceptions.HTTPError): 89 | cdn_transfer._download_config() 90 | 91 | 92 | def test_load_apps_yml(mocker, cdn_transfer): 93 | mock_open = mocker.patch("builtins.open", mocker.mock_open(read_data="mock apps.yml content")) 94 | 95 | cdn_transfer._load_apps_yml() 96 | 97 | mock_open.assert_called_once_with(os.path.join(os.path.dirname(module_file), "../data/apps.yml"), "r") 98 | assert cdn_transfer.apps_yml == "mock apps.yml content" 99 | 100 | 101 | # NOTE: Temporarily disabling this test until we have a reliable means of patching out 102 | # `urllib3` calls. Even after multiple attempts, the call to `request` still 103 | # tries to resolve the mock domain in the mocked CDN object. 104 | # References: 105 | # - https://mozilla-hub.atlassian.net/jira/software/c/projects/IAM/issues/IAM-1403 106 | # 107 | @pytest.mark.skip(reason="Cannot properly mock `urllib3`'s `request` call.") 108 | def test_sync_config_update(mocker, cdn_transfer): 109 | mocker.patch.object(CDNTransfer, "is_updated", return_value=True) 110 | mock_download = mocker.patch.object(CDNTransfer, "_download_config") 111 | mock_load = mocker.patch.object(CDNTransfer, "_load_apps_yml") 112 | 113 | cdn_transfer.sync_config() 114 | 115 | mock_download.assert_called_once() 116 | mock_load.assert_called_once() 117 | 118 | 119 | # NOTE: Temporarily disabling this test until we have a reliable means of patching out 120 | # `urllib3` calls. Even after multiple attempts, the call to `request` still 121 | # tries to resolve the mock domain in the mocked CDN object. 122 | # References: 123 | # - https://mozilla-hub.atlassian.net/jira/software/c/projects/IAM/issues/IAM-1403 124 | # 125 | @pytest.mark.skip(reason="Cannot properly mock `urllib3`'s `request` call.") 126 | def test_sync_config_no_update(mocker, cdn_transfer): 127 | mocker.patch.object(CDNTransfer, "is_updated", return_value=False) 128 | mock_download = mocker.patch.object(CDNTransfer, "_download_config") 129 | mock_load = mocker.patch.object(CDNTransfer, "_load_apps_yml") 130 | 131 | cdn_transfer.sync_config() 132 | 133 | mock_download.assert_not_called() 134 | mock_load.assert_called_once() 135 | 136 | 137 | # NOTE: Temporarily disabling this test until we have a reliable means of patching out 138 | # `urllib3` calls. Even after multiple attempts, the call to `request` still 139 | # tries to resolve the mock domain in the mocked CDN object. 140 | # References: 141 | # - https://mozilla-hub.atlassian.net/jira/software/c/projects/IAM/issues/IAM-1403 142 | # 143 | @pytest.mark.skip(reason="Cannot properly mock `urllib3`'s `request` call.") 144 | def test_sync_config_download_error(mocker, cdn_transfer): 145 | mocker.patch.object(CDNTransfer, "is_updated", return_value=True) 146 | mock_download = mocker.patch.object(CDNTransfer, "_download_config", side_effect=Exception("Test Exception")) 147 | mock_load = mocker.patch.object(CDNTransfer, "_load_apps_yml") 148 | 149 | cdn_transfer.sync_config() 150 | 151 | mock_download.assert_called_once() 152 | mock_load.assert_not_called() # if download fails, it shouldn't try to load 153 | -------------------------------------------------------------------------------- /tests/models/test_user.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | import os 4 | 5 | import dashboard.models.user as user 6 | 7 | 8 | class TestUser: 9 | def setup_method(self): 10 | self.fixture_file = Path(__file__).parent.parent / "data" / "userinfo.json" 11 | try: 12 | with open(self.fixture_file) as f: 13 | self.session_fixture = json.load(f) 14 | 15 | self.good_apps_list = {"apps": []} 16 | 17 | self.u = user.User(session=self.session_fixture, app_config=None) 18 | self.u.api_token = "foo" 19 | except (FileNotFoundError, json.JSONDecodeError, KeyError) as e: 20 | self.u = None 21 | raise RuntimeError(f"Failed to set up TestUser: {str(e)}") 22 | 23 | def test_object_init(self): 24 | assert self.u is not None 25 | 26 | def test_avatar(self): 27 | avatar = self.u.avatar 28 | assert avatar is None 29 | 30 | def test_parsing_groups(self): 31 | groups = self.u.group_membership() 32 | assert len(groups) > 0 33 | 34 | def test_user_name(self): 35 | f_name = self.u.first_name 36 | l_name = self.u.last_name 37 | 38 | assert f_name == "" 39 | assert l_name == "" 40 | 41 | def test_user_identifiers(self): 42 | assert len(self.u.user_identifiers()) == 2 43 | 44 | def test_apps(self): 45 | apps = self.u.apps(self.good_apps_list) 46 | assert apps == [] 47 | -------------------------------------------------------------------------------- /tests/op/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-iam/sso-dashboard/6d5cada0bc21cf4fe0fa9226090e58f90029df96/tests/op/__init__.py -------------------------------------------------------------------------------- /tests/op/test_yaml_loader.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import yaml 3 | from unittest.mock import patch, MagicMock 4 | from dashboard.op.yaml_loader import Application 5 | 6 | # Testing data 7 | apps = """ 8 | apps: 9 | - application: 10 | name: "Test Application" 11 | url: "https://example.com" 12 | vanity_url: 13 | - "/test-app" 14 | - application: 15 | name: "Long Application Name That Exceeds Limit" 16 | url: "https://example-long.com" 17 | vanity_url: 18 | - "/long-app" 19 | """ 20 | 21 | 22 | @pytest.fixture 23 | def valid_application(): 24 | return Application(apps) 25 | 26 | 27 | def test_load_data(valid_application): 28 | assert valid_application.apps is not None 29 | assert len(valid_application.apps["apps"]) == 2 30 | assert valid_application.apps["apps"][1]["application"]["name"] == "Test Application" 31 | 32 | 33 | def test_load_data_invalid_yaml(): 34 | invalid_app = "invalid: : : yaml" 35 | with pytest.raises(TypeError): 36 | Application(invalid_app) 37 | 38 | 39 | def test_alphabetize(valid_application): 40 | assert valid_application.apps["apps"][0]["application"]["name"] == "Long Application Name That Exceeds Limit" 41 | assert valid_application.apps["apps"][1]["application"]["name"] == "Test Application" 42 | 43 | 44 | def test_vanity_urls(valid_application): 45 | redirects = valid_application.vanity_urls() 46 | assert len(redirects) == 2 47 | assert redirects[0] == {"/long-app": "https://example-long.com"} 48 | assert redirects[1] == {"/test-app": "https://example.com"} 49 | 50 | 51 | def test_vanity_urls_no_vanity(): 52 | app_no_vanity = """ 53 | apps: 54 | - application: 55 | name: "No Vanity App" 56 | url: "https://no-vanity.com" 57 | """ 58 | app = Application(app_no_vanity) 59 | redirects = app.vanity_urls() 60 | assert len(redirects) == 0 61 | 62 | 63 | def test_no_apps_present(valid_application): 64 | del valid_application.apps["apps"] 65 | 66 | assert len(valid_application.vanity_urls()) == 0 67 | -------------------------------------------------------------------------------- /tests/sso-dashboard.ini: -------------------------------------------------------------------------------- 1 | [sso-dashboard] 2 | 3 | debug=True 4 | testing=True 5 | csrf_enabled=True 6 | permanent_session=True 7 | permanent_session_lifetime=86400 8 | session_cookie_httponly=True 9 | logger_name=sso-dashboard 10 | preferred_url_scheme=https 11 | server_name=localhost:5000 12 | s3_bucket=foo 13 | 14 | #Optional secret values 15 | oidc_domain=auth-dev.mozilla.auth0.com 16 | oidc_client_id=redacted 17 | oidc_client_secret=redacted 18 | 19 | #Ops features 20 | enable_prometheus_monitoring=True 21 | prometheus_monitoring_port=9000 22 | -------------------------------------------------------------------------------- /tests/test_oidc_auth.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import json 3 | from cryptography.hazmat.primitives.asymmetric import ec 4 | from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat 5 | from josepy.jwk import JWK 6 | from josepy.jws import JWS 7 | from josepy.jwa import ES256 8 | import pytest 9 | from dashboard import oidc_auth 10 | 11 | 12 | class TestValidOidcAuth: 13 | def setup_method(self): 14 | data = Path(__file__).parent / "data" 15 | self.public_key = (data / "public-signing-key.pem").read_text() 16 | self.sample_json_web_token = (data / "mfa-required-jwt").read_text() 17 | 18 | def test_token_verification(self): 19 | tv = oidc_auth.TokenVerification( 20 | jws=self.sample_json_web_token.encode(), 21 | public_key=self.public_key.encode(), 22 | ) 23 | assert tv.signed(), "Could not verify JWS" 24 | 25 | 26 | class TestInvalidOidcAuth: 27 | def setup_method(self): 28 | keypair = ec.generate_private_key(ec.SECP256R1()) 29 | self.private_key = JWK.load( 30 | keypair.private_bytes( 31 | Encoding.PEM, 32 | PrivateFormat.PKCS8, 33 | NoEncryption(), 34 | ) 35 | ) 36 | self.public_key = keypair.public_key().public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo) 37 | 38 | def test_invalid_json_payload(self): 39 | jws = JWS.sign( 40 | protect={"alg"}, 41 | payload=b"this isn't valid json", 42 | key=self.private_key, 43 | alg=ES256, 44 | ) 45 | with pytest.raises(oidc_auth.TokenError): 46 | oidc_auth.TokenVerification(jws.to_compact(), self.public_key) 47 | 48 | def test_empty_json_body(self): 49 | jws = JWS.sign( 50 | alg=ES256, 51 | key=self.private_key, 52 | payload=json.dumps({}).encode("utf-8"), 53 | protect={"alg"}, 54 | ).to_compact() 55 | tv = oidc_auth.TokenVerification(jws, self.public_key) 56 | assert tv.signed(), "should have been signed" 57 | # These don't super matter a whole lot, just checking that we don't 58 | # accidentally throw assertions. 59 | assert tv.error_code is None 60 | assert tv.error_message() is None 61 | 62 | def test_known_error_code(self): 63 | for error_code in oidc_auth.KNOWN_ERROR_CODES: 64 | jws = JWS.sign( 65 | alg=ES256, 66 | key=self.private_key, 67 | payload=json.dumps( 68 | { 69 | "code": error_code, 70 | } 71 | ).encode("utf-8"), 72 | protect={"alg"}, 73 | ).to_compact() 74 | tv = oidc_auth.TokenVerification(jws, self.public_key) 75 | assert tv.error_message() is not None, f"couldn't generate message for {error_code}" 76 | -------------------------------------------------------------------------------- /tests/test_vanity.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import pytest 3 | from flask import Flask 4 | from dashboard import config 5 | from dashboard import vanity 6 | from dashboard.models import tile 7 | from dashboard.op import yaml_loader 8 | 9 | 10 | @pytest.fixture 11 | def externals(monkeypatch): 12 | # Internal to the way dashboard.models.tile.CDNTransfer works. 13 | models_dir = Path(__file__).parent / "models" 14 | monkeypatch.setattr("os.path.dirname", lambda _: models_dir) 15 | # Internal to how Configs work. 16 | monkeypatch.setenv("REDIS_CONNECTOR", "foobar") 17 | monkeypatch.setenv("SECRET_KEY", "deadbeef") 18 | monkeypatch.setenv("S3_BUCKET", "") 19 | monkeypatch.setenv("FORBIDDEN_PAGE_PUBLIC_KEY", "") 20 | monkeypatch.setenv("CDN", "https://localhost") 21 | 22 | 23 | @pytest.fixture 24 | def app_config(externals): 25 | return config.Default() 26 | 27 | 28 | @pytest.fixture 29 | def dashboard_app(app_config): 30 | """ 31 | A mini instance of the app. 32 | """ 33 | app = Flask("dashboard") 34 | app.config.from_object(app_config) 35 | yield app 36 | 37 | 38 | @pytest.fixture 39 | def cdn(app_config): 40 | return tile.CDNTransfer(app_config) 41 | 42 | 43 | @pytest.fixture 44 | def client(dashboard_app): 45 | return dashboard_app.test_client() 46 | 47 | 48 | class TestVanity: 49 | def test_router(self, dashboard_app, cdn, client): 50 | router = vanity.Router(dashboard_app, cdn) 51 | router.setup() 52 | response = client.get("/netlify") 53 | assert response.location == "https://some-url-for-netlify", "Did not properly redirect vanity URL" 54 | -------------------------------------------------------------------------------- /tools/copy-static-files.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | // Define the dependencies and the files to by copied 5 | const dependencies = { 6 | jquery: [ 7 | 'dist/jquery.min.js', 8 | ], 9 | muicss: [ 10 | 'dist/css/mui.min.css', 11 | 'dist/js/mui.min.js', 12 | ], 13 | }; 14 | 15 | const nodeModulesDir = path.resolve(__dirname, '../node_modules'); 16 | const targetDir = path.resolve(__dirname, '../dashboard/static/lib'); 17 | 18 | 19 | // Copy the necessary files from node_modules to the target directory 20 | for (const [pkg, files] of Object.entries(dependencies)) { 21 | 22 | files.forEach((file) => { 23 | const srcPath = path.join(nodeModulesDir, pkg, file); 24 | const destPath = path.join(targetDir, pkg, file); 25 | const destDir = path.dirname(destPath); 26 | 27 | // Ensure the path exists before attempting to copy it 28 | if (!fs.existsSync(destDir)) { 29 | fs.mkdirSync(destDir, { recursive: true }); 30 | } 31 | 32 | // Copy the file to the target 33 | if (fs.existsSync(srcPath)) { 34 | fs.copyFileSync(srcPath, destPath); 35 | console.log(`Copied ${srcPath} to ${destPath}`); 36 | } else { 37 | console.error(`File not found: ${srcPath}`); 38 | } 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | env_list = 3 | eslint 4 | mypy 5 | pylint 6 | pytest 7 | stylelint 8 | minversion = 4.6.0 9 | skip_missing_interpreters = false 10 | 11 | [testenv] 12 | description = base environment 13 | package = wheel 14 | wheel_build_env = .pkg 15 | set_env = 16 | NODE_VERSION=18.20.4 17 | NPM_VERSION=10.9.0 18 | deps = 19 | pytest,mypy: -r requirements.txt 20 | eslint,stylelint: nodeenv 21 | commands_pre = 22 | {es,style}lint: ./ci/node-install.sh 23 | allowlist_externals = 24 | {es,style}lint: ./ci/node-install.sh 25 | 26 | [testenv:pytest] 27 | description = run the tests with pytest 28 | deps = 29 | {[testenv]deps} 30 | pytest>=6 31 | pytest-mock>=3 32 | pytest-cov>=5 33 | commands = pytest {tty:--color=yes} {posargs} 34 | 35 | [testenv:mypy] 36 | description = run mypy 37 | deps = 38 | {[testenv:pytest]deps} 39 | mypy>=1 40 | types-PyYAML>=6.0 41 | types-colorama>=0.4 42 | types-redis>=4.6 43 | types-requests>=2.32 44 | types-six>=1.16 45 | commands = mypy {tty:--color-output:--no-color-output} {posargs: ./tests ./dashboard} 46 | 47 | [testenv:pylint] 48 | description = run python black linters 49 | skip_install = true 50 | deps = 51 | black==24.8.0 52 | commands = black --check {posargs: ./tests ./dashboard} 53 | 54 | [testenv:eslint] 55 | description = run eslint 56 | skip_install = true 57 | commands = npm run lint:js 58 | 59 | [testenv:stylelint] 60 | description = run stylelint 61 | skip_install = true 62 | commands = npm run lint:css 63 | --------------------------------------------------------------------------------