├── .prettierrc ├── icons ├── linux-entra-sso_128.png ├── linux-entra-sso_48.png ├── profile-outline_48.png ├── linux-entra-sso_128.png.license ├── linux-entra-sso_48.png.license ├── profile-outline_48.png.license ├── profile-outline.svg └── linux-entra-sso.svg ├── .prettierrc.license ├── platform ├── chrome │ ├── extension.json │ ├── manifest.json.license │ ├── extension.json.license │ ├── linux_entra_sso.json.license │ ├── storage-schema.json.license │ ├── js │ │ ├── platform-factory.js │ │ └── platform-chrome.js │ ├── storage-schema.json │ ├── linux_entra_sso.json │ ├── get-ext-id.py │ └── manifest.json ├── firefox │ ├── manifest.json.license │ ├── linux_entra_sso.json.license │ ├── js │ │ ├── platform-factory.js │ │ └── platform-firefox.js │ ├── linux_entra_sso.json │ └── manifest.json └── thunderbird │ ├── manifest.json.license │ ├── js │ ├── platform-factory.js │ └── platform-thunderbird.js │ └── manifest.json ├── .pages ├── firefox │ ├── updates.json.license │ └── updates.json └── thunderbird │ ├── updates.json.license │ └── updates.json ├── .gitignore ├── MAINTAINERS.md ├── .github └── workflows │ ├── deploy-update-manifest.yml │ ├── build.yml │ └── release.yml ├── src ├── utils.js ├── platform.js ├── device.js ├── policy.js ├── broker.js ├── background.js └── account.js ├── PRIVACY.md ├── CONTRIBUTING.md ├── docs └── global_install.md ├── popup ├── menu.html ├── menu.css └── menu.js ├── tests └── linux_entra_sso_mock.py ├── README.md ├── Makefile ├── linux-entra-sso.py ├── LICENSE └── LICENSES └── MPL-2.0.txt /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4 3 | } 4 | -------------------------------------------------------------------------------- /icons/linux-entra-sso_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siemens/linux-entra-sso/HEAD/icons/linux-entra-sso_128.png -------------------------------------------------------------------------------- /icons/linux-entra-sso_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siemens/linux-entra-sso/HEAD/icons/linux-entra-sso_48.png -------------------------------------------------------------------------------- /icons/profile-outline_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/siemens/linux-entra-sso/HEAD/icons/profile-outline_48.png -------------------------------------------------------------------------------- /.prettierrc.license: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MPL-2.0 2 | # SPDX-FileCopyrightText: Copyright (c) Siemens AG, 2024 3 | -------------------------------------------------------------------------------- /platform/chrome/extension.json: -------------------------------------------------------------------------------- 1 | { 2 | "external_update_url": "https://clients2.google.com/service/update2/crx" 3 | } 4 | -------------------------------------------------------------------------------- /.pages/firefox/updates.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: Copyright 2024 Siemens AG 2 | 3 | SPDX-License-Identifier: MPL-2.0 4 | -------------------------------------------------------------------------------- /.pages/thunderbird/updates.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: Copyright 2025 Siemens 2 | 3 | SPDX-License-Identifier: MPL-2.0 4 | -------------------------------------------------------------------------------- /icons/linux-entra-sso_128.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: Copyright 2024 Siemens AG 2 | 3 | SPDX-License-Identifier: MPL-2.0 4 | -------------------------------------------------------------------------------- /icons/linux-entra-sso_48.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: Copyright 2024 Siemens AG 2 | 3 | SPDX-License-Identifier: MPL-2.0 4 | -------------------------------------------------------------------------------- /icons/profile-outline_48.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: Copyright 2021 YANDEX LLC 2 | 3 | SPDX-License-Identifier: MPL-2.0 4 | -------------------------------------------------------------------------------- /platform/chrome/manifest.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: Copyright 2024 Siemens AG 2 | 3 | SPDX-License-Identifier: MPL-2.0 4 | -------------------------------------------------------------------------------- /platform/chrome/extension.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: Copyright 2024 Siemens AG 2 | 3 | SPDX-License-Identifier: MPL-2.0 4 | -------------------------------------------------------------------------------- /platform/firefox/manifest.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: Copyright 2024 Siemens AG 2 | 3 | SPDX-License-Identifier: MPL-2.0 4 | -------------------------------------------------------------------------------- /platform/thunderbird/manifest.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: Copyright 2025 Siemens 2 | 3 | SPDX-License-Identifier: MPL-2.0 4 | -------------------------------------------------------------------------------- /platform/chrome/linux_entra_sso.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: Copyright 2024 Siemens AG 2 | 3 | SPDX-License-Identifier: MPL-2.0 4 | -------------------------------------------------------------------------------- /platform/chrome/storage-schema.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: Copyright 2025 Siemens AG 2 | 3 | SPDX-License-Identifier: MPL-2.0 4 | -------------------------------------------------------------------------------- /platform/firefox/linux_entra_sso.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: Copyright 2024 Siemens AG 2 | 3 | SPDX-License-Identifier: MPL-2.0 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright 2024 Siemens AG 2 | # 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | Linux-Entra-SSO*.xpi 6 | __pycache__/ 7 | build 8 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | # Maintainers 7 | 8 | - Felix Moessbauer (fmoessbauer) 9 | - Jan Kiszka (jan-kiszka) 10 | -------------------------------------------------------------------------------- /platform/chrome/js/platform-factory.js: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: MPL-2.0 3 | * SPDX-FileCopyrightText: Copyright 2025 Siemens 4 | */ 5 | 6 | import { PlatformChrome } from "./platform-chrome.js"; 7 | 8 | export function create_platform() { 9 | return new PlatformChrome(); 10 | } 11 | -------------------------------------------------------------------------------- /platform/firefox/js/platform-factory.js: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: MPL-2.0 3 | * SPDX-FileCopyrightText: Copyright 2025 Siemens 4 | */ 5 | 6 | import { PlatformFirefox } from "./platform-firefox.js"; 7 | 8 | export function create_platform() { 9 | return new PlatformFirefox(); 10 | } 11 | -------------------------------------------------------------------------------- /platform/thunderbird/js/platform-factory.js: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: MPL-2.0 3 | * SPDX-FileCopyrightText: Copyright 2025 Siemens 4 | */ 5 | 6 | import { PlatformThunderbird } from "./platform-thunderbird.js"; 7 | 8 | export function create_platform() { 9 | return new PlatformThunderbird(); 10 | } 11 | -------------------------------------------------------------------------------- /platform/chrome/storage-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "wellKnownApps": { 5 | "description": "Apps that are allowed to perform background SSO.", 6 | "type": "object", 7 | "additionalProperties": { 8 | "type": "boolean" 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /platform/thunderbird/js/platform-thunderbird.js: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: MPL-2.0 3 | * SPDX-FileCopyrightText: Copyright 2025 Siemens 4 | */ 5 | 6 | import { PlatformFirefox } from "./platform-firefox.js"; 7 | 8 | export class PlatformThunderbird extends PlatformFirefox { 9 | browser = "Thunderbird"; 10 | } 11 | -------------------------------------------------------------------------------- /platform/firefox/linux_entra_sso.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linux_entra_sso", 3 | "description": "Entra ID SSO via Microsoft Identity Broker", 4 | "path": "/usr/local/lib/linux-entra-sso/linux-entra-sso.py", 5 | "type": "stdio", 6 | "allowed_extensions": [ 7 | "linux-entra-sso@example.com", 8 | "@linux-entra-sso.tb" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /platform/chrome/linux_entra_sso.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linux_entra_sso", 3 | "description": "Entra ID SSO via Microsoft Identity Broker", 4 | "path": "/usr/local/lib/linux-entra-sso/linux-entra-sso.py", 5 | "type": "stdio", 6 | "allowed_origins": [ 7 | "chrome-extension://{extension_id}/", 8 | "chrome-extension://jlnfnnolkbjieggibinobhkjdfbpcohn/" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /platform/chrome/get-ext-id.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-FileCopyrightText: Copyright 2024 Siemens AG 3 | # SPDX-License-Identifier: MPL-2.0 4 | # 5 | # Compute the extension ID from the path of the extension 6 | # (for unpacked extensions). 7 | 8 | import hashlib 9 | import sys 10 | import os 11 | 12 | if len(sys.argv) != 2: 13 | print("Usage: python get-ext-id.py ") 14 | 15 | PATH = os.path.realpath(sys.argv[1]) 16 | m = hashlib.sha256() 17 | m.update(bytes(PATH.encode("utf-8"))) 18 | EXTID = "".join([chr(int(i, base=16) + ord("a")) for i in m.hexdigest()][:32]) 19 | print(EXTID) 20 | -------------------------------------------------------------------------------- /icons/profile-outline.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 16 | 18 | 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/deploy-update-manifest.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright 2024 Siemens AG 2 | # 3 | # SPDX-License-Identifier: MPL-2.0 4 | name: Deploy Firefox Addon Update Manifest 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | 10 | workflow_dispatch: 11 | 12 | permissions: 13 | contents: read 14 | 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: false 18 | 19 | jobs: 20 | deploy: 21 | permissions: 22 | pages: write 23 | id-token: write 24 | environment: 25 | name: github-pages 26 | url: ${{ steps.deployment.outputs.page_url }} 27 | runs-on: ubuntu-24.04 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | - name: Setup Pages 32 | uses: actions/configure-pages@v5 33 | - name: Upload artifact 34 | uses: actions/upload-pages-artifact@v3 35 | with: 36 | path: '.pages/' 37 | - name: Deploy to GitHub Pages 38 | id: deployment 39 | uses: actions/deploy-pages@v4 40 | -------------------------------------------------------------------------------- /platform/chrome/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Entra ID SSO via Microsoft Identity Broker", 3 | "manifest_version": 3, 4 | "name": "Linux Entra SSO", 5 | "version": "1.7.1", 6 | "icons": { 7 | "48": "icons/linux-entra-sso_48.png", 8 | "128": "icons/linux-entra-sso_128.png" 9 | }, 10 | "action": { 11 | "default_popup": "popup/menu.html", 12 | "default_icon": { 13 | "48": "icons/linux-entra-sso_48.png", 14 | "128": "icons/linux-entra-sso_128.png" 15 | }, 16 | "default_title": "Linux Entra SSO", 17 | "default_area": "navbar" 18 | }, 19 | "background": { 20 | "service_worker": "/src/background.js", 21 | "type": "module" 22 | }, 23 | "permissions": [ 24 | "alarms", 25 | "nativeMessaging", 26 | "declarativeNetRequest", 27 | "storage", 28 | "activeTab" 29 | ], 30 | "host_permissions": [ 31 | "https://login.microsoftonline.com/*" 32 | ], 33 | "optional_host_permissions": [ 34 | "https://*/*" 35 | ], 36 | "storage": { 37 | "managed_schema": "storage-schema.json" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /platform/firefox/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Entra ID SSO via Microsoft Identity Broker", 3 | "manifest_version": 3, 4 | "name": "Linux Entra SSO", 5 | "version": "1.7.1", 6 | "icons": { 7 | "48": "icons/linux-entra-sso.svg" 8 | }, 9 | "browser_specific_settings": { 10 | "gecko": { 11 | "id": "linux-entra-sso@example.com", 12 | "strict_min_version": "128.0", 13 | "update_url": "https://siemens.github.io/linux-entra-sso/firefox/updates.json" 14 | } 15 | }, 16 | "action": { 17 | "default_popup": "popup/menu.html", 18 | "default_icon": "icons/linux-entra-sso.svg", 19 | "default_title": "Linux Entra SSO", 20 | "default_area": "navbar" 21 | }, 22 | "background": { 23 | "scripts": [ "/src/background.js" ], 24 | "type": "module" 25 | }, 26 | "permissions": [ 27 | "nativeMessaging", 28 | "webRequest", 29 | "webRequestBlocking", 30 | "storage", 31 | "activeTab" 32 | ], 33 | "host_permissions": [ 34 | "https://login.microsoftonline.com/*" 35 | ], 36 | "optional_host_permissions": [ 37 | "https://*/*" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /platform/thunderbird/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Entra ID SSO via Microsoft Identity Broker", 3 | "manifest_version": 3, 4 | "name": "Linux Entra SSO", 5 | "version": "1.7.1", 6 | "icons": { 7 | "48": "icons/linux-entra-sso.svg" 8 | }, 9 | "browser_specific_settings": { 10 | "gecko": { 11 | "id": "@linux-entra-sso.tb", 12 | "strict_min_version": "128.0", 13 | "update_url": "https://siemens.github.io/linux-entra-sso/thunderbird/updates.json" 14 | } 15 | }, 16 | "action": { 17 | "default_popup": "popup/menu.html", 18 | "default_icon": "icons/linux-entra-sso.svg", 19 | "default_title": "Linux Entra SSO", 20 | "allowed_spaces": [ 21 | "mail", 22 | "calendar", 23 | "addressbook", 24 | "tasks", 25 | "settings", 26 | "default" 27 | ] 28 | }, 29 | "background": { 30 | "scripts": [ "/src/background.js" ], 31 | "type": "module" 32 | }, 33 | "permissions": [ 34 | "nativeMessaging", 35 | "webRequest", 36 | "webRequestBlocking", 37 | "storage" 38 | ], 39 | "host_permissions": [ 40 | "https://login.microsoftonline.com/*" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: MPL-2.0 3 | * SPDX-FileCopyrightText: Copyright 2025 Siemens 4 | */ 5 | 6 | export function ssoLog(message) { 7 | console.log("[Linux Entra SSO] " + message); 8 | } 9 | 10 | export function ssoLogError(message) { 11 | console.error("[Linux Entra SSO] " + message); 12 | } 13 | 14 | export async function load_icon(path, width) { 15 | const response = await fetch(chrome.runtime.getURL(path)); 16 | let imgBitmap = await createImageBitmap(await response.blob(), { 17 | resizeWidth: width, 18 | resizeHeight: width, 19 | }); 20 | let canvas = new OffscreenCanvas(width, width); 21 | let ctx = canvas.getContext("2d"); 22 | ctx.save(); 23 | ctx.drawImage(imgBitmap, 0, 0); 24 | ctx.restore(); 25 | return ctx.getImageData(0, 0, width, width); 26 | } 27 | 28 | export function jwt_get_payload(token) { 29 | var base64Url = token.split(".")[1]; 30 | var base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); 31 | var jsonPayload = decodeURIComponent( 32 | atob(base64) 33 | .split("") 34 | .map(function (c) { 35 | return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); 36 | }) 37 | .join(""), 38 | ); 39 | return JSON.parse(jsonPayload); 40 | } 41 | 42 | /** 43 | * Promise that can externally be resolved or rejected. 44 | */ 45 | export class Deferred { 46 | constructor() { 47 | this.promise = new Promise((resolve, reject) => { 48 | this.reject = reject; 49 | this.resolve = resolve; 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.pages/thunderbird/updates.json: -------------------------------------------------------------------------------- 1 | { 2 | "addons": { 3 | "@linux-entra-sso.tb": { 4 | "updates": [ 5 | { 6 | "version": "1.5.0", 7 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.5.0/linux_entra_sso-1.5.0.thunderbird.xpi", 8 | "update_hash": "sha256:d0e47ead833030a178a33934b634ed929b94faf265a2d1a63e51f6a02c61df49" 9 | }, 10 | { 11 | "version": "1.5.1", 12 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.5.1/linux_entra_sso-1.5.1.thunderbird.xpi", 13 | "update_hash": "sha256:3f145d3bebaef978acf4629f70b5ee646069fdc7d838499f2cf1c4ebab7d42fe" 14 | }, 15 | { 16 | "version": "1.6.0", 17 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.6.0/linux_entra_sso-1.6.0.thunderbird.xpi", 18 | "update_hash": "sha256:8121ef5745bf67f44694d4a52d8cc70f5ea6c4c9ba2eb0c32f28d399d0c35733" 19 | }, 20 | { 21 | "version": "1.7.0", 22 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.7.0/linux_entra_sso-1.7.0.thunderbird.xpi", 23 | "update_hash": "sha256:1d19712d85cfeb0765936e285388073a097d4b8b3fcdef711152b2d4d0c09fce" 24 | }, 25 | { 26 | "version": "1.7.1", 27 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.7.1/linux_entra_sso-1.7.1.thunderbird.xpi", 28 | "update_hash": "sha256:53b500deedaaac018260f6e78957e6dd3eca2750b6e335d2632d3817c5c72cb4" 29 | } 30 | ] 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/platform.js: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: MPL-2.0 3 | * SPDX-FileCopyrightText: Copyright 2025 Siemens 4 | */ 5 | 6 | import { Deferred } from "./utils.js"; 7 | 8 | export class Platform { 9 | static SSO_URL = "https://login.microsoftonline.com"; 10 | 11 | browser; 12 | 13 | host_versions = { 14 | native: null, 15 | broker: null, 16 | }; 17 | 18 | /* references needed for PRT injection */ 19 | broker = null; 20 | account = null; 21 | well_known_app_filters = []; 22 | sso_url_permitted = true; 23 | 24 | constructor() { 25 | /* 26 | * The WebRequest API operates on allowed URLs only. 27 | * To intercept a sub-resource request (e.g. from an iframe), the extension 28 | * must have access to both the requested URL and its initiator. 29 | */ 30 | this.well_known_app_filters = [Platform.SSO_URL + "/*"]; 31 | } 32 | 33 | /** 34 | * Load platform information from backend. 35 | */ 36 | async setup(broker) { 37 | this.host_versions = await broker.getVersion(); 38 | } 39 | 40 | setIconDisabled() { 41 | chrome.action.setIcon({ 42 | path: { 43 | 48: "/icons/linux-entra-sso_48.png", 44 | 128: "/icons/linux-entra-sso_128.png", 45 | }, 46 | }); 47 | } 48 | 49 | getSsoUrl() { 50 | return Platform.SSO_URL; 51 | } 52 | 53 | update_request_handlers(enabled, account, broker) { 54 | this.broker = broker; 55 | this.account = account; 56 | } 57 | 58 | async update_host_permissions() { 59 | const currentPermissions = await chrome.permissions.getAll(); 60 | this.well_known_app_filters = currentPermissions.origins; 61 | 62 | // check if we have access to the SSO url 63 | let dfd = new Deferred(); 64 | const permissionsToCheck = { 65 | origins: [Platform.SSO_URL + "/*"], 66 | }; 67 | chrome.permissions.contains(permissionsToCheck).then((result) => { 68 | this.sso_url_permitted = result; 69 | dfd.resolve(); 70 | }); 71 | await dfd.promise; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/device.js: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: MPL-2.0 3 | * SPDX-FileCopyrightText: Copyright 2025 Siemens 4 | */ 5 | 6 | import { ssoLog, ssoLogError, jwt_get_payload } from "./utils.js"; 7 | 8 | export class Device { 9 | name = null; 10 | compliant = null; 11 | 12 | constructor(name, compliant) { 13 | this.name = name; 14 | this.compliant = compliant; 15 | } 16 | } 17 | 18 | export class DeviceManager { 19 | static DEVICE_REFRESH_INTERVAL_MIN = 30; 20 | 21 | #am = null; 22 | #last_refresh = 0; 23 | device = null; 24 | 25 | constructor(account_manager) { 26 | this.#am = account_manager; 27 | this.device = null; 28 | } 29 | 30 | async updateDeviceInfo() { 31 | if ( 32 | Date.now() < 33 | this.#last_refresh + 34 | DeviceManager.DEVICE_REFRESH_INTERVAL_MIN * 60 * 1000 35 | ) { 36 | return false; 37 | } 38 | await this.loadDeviceInfo(); 39 | return true; 40 | } 41 | 42 | async loadDeviceInfo() { 43 | if (!this.#am.hasAccounts()) { 44 | return; 45 | } 46 | const graph_token = await this.#am.getToken( 47 | this.#am.getRegistered()[0], 48 | ); 49 | const grants = jwt_get_payload(graph_token); 50 | if ((!"deviceid") in grants) { 51 | ssoLog("access token does not have deviceid grant"); 52 | return; 53 | } 54 | const response = await fetch( 55 | `https://graph.microsoft.com/v1.0/devices(deviceId='{${grants["deviceid"]}}')?$select=isCompliant,displayName`, 56 | { 57 | headers: { 58 | Accept: "application/json", 59 | Authorization: "Bearer " + graph_token, 60 | }, 61 | }, 62 | ); 63 | if (!response.ok) { 64 | ssoLogError("failed to query device state"); 65 | return; 66 | } 67 | const data = await response.json(); 68 | this.#last_refresh = Date.now(); 69 | this.device = new Device(data.displayName, data.isCompliant); 70 | ssoLog("updated device information"); 71 | } 72 | 73 | getDevice() { 74 | return this.device; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /platform/firefox/js/platform-firefox.js: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: MPL-2.0 3 | * SPDX-FileCopyrightText: Copyright 2025 Siemens 4 | */ 5 | 6 | import { Platform } from "./platform.js"; 7 | import { ssoLog } from "./utils.js"; 8 | 9 | export class PlatformFirefox extends Platform { 10 | browser = "Firefox"; 11 | /* PRT injection state */ 12 | #on_before_send_headers = null; 13 | 14 | constructor() { 15 | super(); 16 | /* 17 | * We need to bind, as the handler is called from a different context. 18 | * To be able to deregister the handler, we need to assign it to a 19 | * named symbol. 20 | */ 21 | this.#on_before_send_headers = this.#onBeforeSendHeaders.bind(this); 22 | } 23 | 24 | setIconDisabled() { 25 | chrome.action.setIcon({ 26 | path: "/icons/linux-entra-sso.svg", 27 | }); 28 | } 29 | 30 | update_request_handlers(enabled, account, broker) { 31 | super.update_request_handlers(enabled, account, broker); 32 | chrome.webRequest.onBeforeSendHeaders.removeListener( 33 | this.#on_before_send_headers, 34 | ); 35 | 36 | if (!enabled || this.well_known_app_filters.length == 0) return; 37 | chrome.webRequest.onBeforeSendHeaders.addListener( 38 | this.#on_before_send_headers, 39 | { 40 | urls: this.well_known_app_filters, 41 | types: ["main_frame", "sub_frame"], 42 | }, 43 | ["blocking", "requestHeaders"], 44 | ); 45 | } 46 | 47 | async #onBeforeSendHeaders(e) { 48 | // filter out requests that are not part of the OAuth2.0 flow 49 | if (!e.url.startsWith(Platform.SSO_URL)) { 50 | return { requestHeaders: e.requestHeaders }; 51 | } 52 | let prt = await this.broker.acquirePrtSsoCookie(this.account, e.url); 53 | if ("error" in prt) { 54 | return { requestHeaders: e.requestHeaders }; 55 | } 56 | // ms-oapxbc OAuth2 protocol extension 57 | ssoLog("inject PRT SSO into request headers"); 58 | e.requestHeaders.push({ 59 | name: prt.cookieName, 60 | value: prt.cookieContent, 61 | }); 62 | return { requestHeaders: e.requestHeaders }; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/policy.js: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: MPL-2.0 3 | * SPDX-FileCopyrightText: Copyright 2025 Siemens 4 | */ 5 | 6 | import { ssoLog, Deferred } from "./utils.js"; 7 | 8 | export class PolicyManager { 9 | static MANAGED_POLICIES_KEY = "wellKnownApps"; 10 | #apps = null; 11 | 12 | async load_policies() { 13 | let dfd = new Deferred(); 14 | chrome.storage.managed.get( 15 | PolicyManager.MANAGED_POLICIES_KEY, 16 | (data) => { 17 | if ( 18 | typeof data === "object" && 19 | data.hasOwnProperty("wellKnownApps") 20 | ) { 21 | this.#apps = { ...data.wellKnownApps }; 22 | ssoLog("managed policies loaded"); 23 | } 24 | dfd.resolve(); 25 | }, 26 | ); 27 | return dfd.promise; 28 | } 29 | 30 | getPolicyUpdate(active_app_filters) { 31 | function matches_filter(app, policy) { 32 | return ( 33 | app.replace("*://", "https://") == "https://" + policy + "/*" 34 | ); 35 | } 36 | 37 | const catch_all = active_app_filters.find((value) => 38 | matches_filter(value, "*"), 39 | ); 40 | let gpo_update = { 41 | pending: false, 42 | filters_to_add: [], 43 | filters_to_remove: [], 44 | has_catch_all: catch_all !== undefined, 45 | apps_managed: this.#apps, 46 | }; 47 | if (this.#apps === null) return gpo_update; 48 | 49 | if (gpo_update.has_catch_all) { 50 | gpo_update.filters_to_remove.push(catch_all); 51 | gpo_update.pending = true; 52 | } 53 | for (const [app, enabled] of Object.entries(this.#apps)) { 54 | let filter = active_app_filters.find((value) => 55 | matches_filter(value, app), 56 | ); 57 | if (!enabled && filter !== undefined) { 58 | gpo_update.filters_to_remove.push(filter); 59 | gpo_update.pending = true; 60 | } else if (enabled && filter === undefined) { 61 | gpo_update.filters_to_add.push("https://" + app + "/*"); 62 | gpo_update.pending = true; 63 | } 64 | } 65 | 66 | return gpo_update; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | # Privacy Policy 7 | 8 | The `linux-entra-sso` browser extension does not collect any data of any kind. 9 | 10 | - `linux-entra-sso` has no home server 11 | - `linux-entra-sso` doesn't embed any analytic or telemetry hooks in its code 12 | 13 | To fulfill its purpose, the extension interfaces with the following services: 14 | 15 | - Microsoft Graph API (web service) 16 | - Microsoft Entra ID (web service) 17 | - `com.microsoft.identity.broker1` (`broker`, DBus service) 18 | 19 | ## Microsoft Graph API 20 | 21 | To show data about the currently logged in user (e.g. the profile picture in 22 | the app icon), we request an access token for the `graph.microsoft.com` API. 23 | The token is acquired from the locally running broker. 24 | 25 | ## Microsoft Identity Broker DBus service (broker) 26 | 27 | To implement the SSO functionality, a `PRT SSO Cookie` is requested from the 28 | locally running `com.microsoft.identity.broker1` DBus service. In the Firefox 29 | version, whenever an URL starting with `https://login.microsoftonline.com/` 30 | (Entra ID login URL) is accessed, a token is requested with the full request 31 | URL. On Chrome and Chromium, the `PRT SSO Cookie` is requested periodically 32 | with a generic URL. The returned token is injected into all http requests 33 | hitting the Entra ID login URL. 34 | 35 | ### Note on required and optional host permissions 36 | 37 | We use the `WebRequest` (Firefox) or `declarativeNetRequest` (Chrome) API to 38 | inject the `PRT SSO Cookie` into requests targeting the login provider. To support 39 | this, we need the permission to access your data on `https://login.microsoftonline.com/`. 40 | This permission is (usually) requested at extension install time (required permission). 41 | 42 | For single-page applications (SPAs, like the Teams PWA) that perform automated token 43 | refreshes in the background, we further need the permission to access your data on 44 | the corresponding domains. To minimize the number of permissions we request, we provide users 45 | with the ability to grant these permissions on a case-by-case basis via the extension's UI or policy settings. 46 | Granted permissions can also be revoked through the same interface. 47 | 48 | ## Privacy statement for Microsoft services 49 | 50 | The privacy statement for all Microsoft provided services is found on 51 | . 52 | -------------------------------------------------------------------------------- /icons/linux-entra-sso.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 62 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright 2024 Siemens AG 2 | # 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | name: build browser extension 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | pull_request: 12 | workflow_dispatch: 13 | 14 | # Sets permissions of the GITHUB_TOKEN to checkout the repository 15 | permissions: 16 | contents: read 17 | 18 | env: 19 | WEB_EXT_VERS: 8.9.0 20 | 21 | jobs: 22 | reuse-and-codestyle: 23 | runs-on: ubuntu-24.04 24 | steps: 25 | - name: checkout repository 26 | uses: actions/checkout@v4 27 | 28 | - uses: actions/setup-python@v5 29 | with: 30 | python-version: '3.12' 31 | 32 | - name: install dependencies 33 | run: | 34 | sudo apt-get update && sudo apt-get install -y python3-gi pylint python3-pydbus black 35 | pip3 install --break-system-packages fsfe-reuse 36 | git clean -f -d 37 | 38 | - name: execute linters 39 | run: | 40 | reuse lint 41 | pylint linux-entra-sso.py 42 | 43 | - name: check Python code formatting 44 | run: black --diff --check . 45 | 46 | - name: run prettier 47 | uses: creyD/prettier_action@v4.6 48 | with: 49 | dry: true 50 | prettier_options: '--check **/*.{js,html,css}' 51 | 52 | build-xpi: 53 | runs-on: ubuntu-24.04 54 | permissions: 55 | id-token: write 56 | attestations: write 57 | steps: 58 | - name: checkout repository 59 | uses: actions/checkout@v4 60 | with: 61 | fetch-depth: 0 62 | 63 | - name: install dependencies 64 | run: | 65 | sudo apt update && sudo apt install -y make git zip 66 | 67 | - run: make package deb 68 | 69 | - name: "web-ext lint Firefox" 70 | run: | 71 | npx web-ext@${{ env.WEB_EXT_VERS }} lint --source-dir build/firefox --self-hosted 72 | 73 | - name: "web-ext lint Thunderbird" 74 | run: | 75 | npx web-ext@${{ env.WEB_EXT_VERS }} lint --source-dir build/thunderbird --self-hosted 76 | 77 | - name: upload Firefox and Thunderbird extension 78 | uses: actions/upload-artifact@v4 79 | with: 80 | name: mozilla-xpi 81 | path: | 82 | build/**/*.xpi 83 | 84 | - name: upload chrome extension zip 85 | uses: actions/upload-artifact@v4 86 | with: 87 | name: chrome-zip 88 | path: | 89 | build/chrome/ 90 | 91 | - name: upload debian package 92 | uses: actions/upload-artifact@v4 93 | with: 94 | name: debian-package 95 | path: | 96 | pkgs/linux-entra-sso_*.deb 97 | 98 | - name: attest extension artifacts 99 | uses: actions/attest-build-provenance@v1 100 | if: github.event_name == 'push' 101 | with: 102 | subject-path: | 103 | build/Linux-Entra-SSO-v* 104 | pkgs/linux-entra-sso_*.deb 105 | -------------------------------------------------------------------------------- /platform/chrome/js/platform-chrome.js: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: MPL-2.0 3 | * SPDX-FileCopyrightText: Copyright 2025 Siemens 4 | */ 5 | 6 | import { Platform } from "./platform.js"; 7 | import { ssoLog } from "./utils.js"; 8 | 9 | export class PlatformChrome extends Platform { 10 | browser = "Chrome"; 11 | #update_net_rules_cb = null; 12 | 13 | static CHROME_PRT_SSO_REFRESH_INTERVAL_MIN = 30; 14 | 15 | constructor() { 16 | super(); 17 | this.#update_net_rules_cb = this.#update_net_rules.bind(this); 18 | } 19 | 20 | update_request_handlers(enabled, account, broker) { 21 | super.update_request_handlers(enabled, account, broker); 22 | if (!enabled) { 23 | chrome.alarms.onAlarm.removeListener(this.#update_net_rules_cb); 24 | chrome.alarms.clear("prt-sso-refresh"); 25 | this.#clear_net_rules(); 26 | return; 27 | } 28 | this.#ensure_refresh_alarm("prt-sso-refresh"); 29 | this.#update_net_rules(); 30 | } 31 | 32 | /* 33 | * Ensure the alarm is armed exactly once. 34 | */ 35 | async #ensure_refresh_alarm(alarm_id) { 36 | const alarm = await chrome.alarms.get(alarm_id); 37 | if (!alarm) { 38 | await chrome.alarms.create(alarm_id, { 39 | periodInMinutes: 40 | PlatformChrome.CHROME_PRT_SSO_REFRESH_INTERVAL_MIN, 41 | }); 42 | } 43 | if (!chrome.alarms.onAlarm.hasListener(this.#update_net_rules_cb)) { 44 | chrome.alarms.onAlarm.addListener(this.#update_net_rules_cb); 45 | } 46 | } 47 | 48 | async #clear_net_rules() { 49 | ssoLog("clear network rules"); 50 | const oldRules = await chrome.declarativeNetRequest.getSessionRules(); 51 | const oldRuleIds = oldRules.map((rule) => rule.id); 52 | await chrome.declarativeNetRequest.updateSessionRules({ 53 | removeRuleIds: oldRuleIds, 54 | }); 55 | } 56 | 57 | async #update_net_rules(e) { 58 | ssoLog("update network rules"); 59 | let prt = await this.broker.acquirePrtSsoCookie( 60 | this.account, 61 | Platform.SSO_URL, 62 | ); 63 | if ("error" in prt) { 64 | ssoLogError("could not acquire PRT SSO cookie: " + prt.error); 65 | return; 66 | } 67 | const newRules = [ 68 | { 69 | id: 1, 70 | priority: 1, 71 | condition: { 72 | urlFilter: Platform.SSO_URL + "/*", 73 | resourceTypes: ["main_frame", "sub_frame"], 74 | }, 75 | action: { 76 | type: "modifyHeaders", 77 | requestHeaders: [ 78 | { 79 | header: prt.cookieName, 80 | operation: "set", 81 | value: prt.cookieContent, 82 | }, 83 | ], 84 | }, 85 | }, 86 | ]; 87 | const oldRules = await chrome.declarativeNetRequest.getSessionRules(); 88 | const oldRuleIds = oldRules.map((rule) => rule.id); 89 | 90 | // Use the arrays to update the dynamic rules 91 | await chrome.declarativeNetRequest.updateSessionRules({ 92 | removeRuleIds: oldRuleIds, 93 | addRules: newRules, 94 | }); 95 | ssoLog("network rules updated"); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | # Contributing to linux-entra-sso 7 | 8 | Contributions are always welcome. This document explains the 9 | general requirements on contributions and the recommended preparation 10 | steps. 11 | 12 | ## Contribution Checklist 13 | 14 | - use git to manage your changes [*recommended*] 15 | - follow Python coding style outlined in pep8 [**required**] 16 | - add signed-off to all patches [**required**] 17 | - to certify the "Developer's Certificate of Origin", see below 18 | - check with your employer when not working on your own! 19 | - post follow-up version(s) if feedback requires this 20 | - send reminder if nothing happened after about a week 21 | - when adding new files, add a license header (see existing files) [**required**] 22 | 23 | Developer's Certificate of Origin 1.1 24 | ------------------------------------- 25 | 26 | When signing-off a patch for this project like this 27 | 28 | Signed-off-by: Random J Developer 29 | 30 | using your real name (no pseudonyms or anonymous contributions), you declare the 31 | following: 32 | 33 | By making a contribution to this project, I certify that: 34 | 35 | (a) The contribution was created in whole or in part by me and I 36 | have the right to submit it under the open source license 37 | indicated in the file; or 38 | 39 | (b) The contribution is based upon previous work that, to the best 40 | of my knowledge, is covered under an appropriate open source 41 | license and I have the right under that license to submit that 42 | work with modifications, whether created in whole or in part 43 | by me, under the same open source license (unless I am 44 | permitted to submit under a different license), as indicated 45 | in the file; or 46 | 47 | (c) The contribution was provided directly to me by some other 48 | person who certified (a), (b) or (c) and I have not modified 49 | it. 50 | 51 | (d) I understand and agree that this project and the contribution 52 | are public and that a record of the contribution (including all 53 | personal information I submit with it, including my sign-off) is 54 | maintained indefinitely and may be redistributed consistent with 55 | this project or the open source license(s) involved. 56 | 57 | ## Testing 58 | 59 | Please test the extension on all supported platforms (browsers). 60 | If you cannot test on a platform (e.g., because you don't have it), clearly state this. 61 | We also provide a mock implementation of the backend part, which can be installed using `make local-install-mock`. 62 | This mock processes and returns syntactically valid data via the native messaging protocol, 63 | enabling testing of features like multi-account support that are otherwise difficult to test. 64 | It does not require a `microsoft-identity-broker` to be running but also does not issue valid tokens. 65 | 66 | ## Maintainers: Create Releases 67 | 68 | The creation of public releases is a partially automated process: 69 | 70 | 1. update code and create release tags: `VERSION= make release` 71 | 2. push to GitHub: `git push origin main && git push origin v` 72 | 3. wait for release action to finish (public release is created) 73 | 4. add release-notes to public release 74 | 5. manually inspect signed xpi (double check) 75 | 6. merge auto-created MR to enroll Firefox update manifest 76 | 7. publish CWS upload (answer questions on permission changes) 77 | 8. wait for CWS to review and sign extension, upload `.crx` to releases page 78 | -------------------------------------------------------------------------------- /docs/global_install.md: -------------------------------------------------------------------------------- 1 | 5 | # System-Wide Install via Policy 6 | 7 | We support both system-wide installation and managed configuration. 8 | 9 | ## Host Components 10 | 11 | Linux distributions can include the host components by packaging the output of `make install` (using `DESTDIR` is supported). 12 | This makes the host parts available to all users, but requires the use of signed extension versions. 13 | 14 | The native messaging directories differ across Linux distributions. 15 | The variables `(firefox|chrome|chromium)_nm_dir` and `chrome_ext_dir` must be configured appropriately. 16 | The Python interpreter (shebang) is determined at install time to avoid runtime dependencies on venvs. 17 | This can be adjusted by setting `python3_bin`. 18 | The default values are suitable for a Debian system. For more information, refer to the `Makefile`. 19 | 20 | ## Webextension 21 | 22 | On Chrome, the `make install` target takes care of registering the extension to be auto-installed when starting the browser. 23 | 24 | On other browsers, the installation of the extension is controlled via a policy. 25 | The paths of the policy files may vary across browsers and distributions. 26 | On Debian, the following paths are known to work. 27 | 28 | ### Firefox 29 | 30 | Example: `/etc/firefox/policies/policies.json` 31 | 32 | ```json 33 | { 34 | "policies": { 35 | "ExtensionSettings": { 36 | "linux-entra-sso@example.com": { 37 | "installation_mode": "force_installed", 38 | "install_url": "file:///path/to/extension.xpi" 39 | } 40 | } 41 | } 42 | } 43 | ``` 44 | 45 | ### Chromium 46 | 47 | Example: `/etc/chromium/policies/managed/policies.json` 48 | 49 | ```json 50 | { 51 | "ExtensionSettings": { 52 | "jlnfnnolkbjieggibinobhkjdfbpcohn": { 53 | "runtime_allowed_hosts": ["https://login.microsoftonline.com"], 54 | "installation_mode": "force_installed", 55 | "update_url": "file:///path/to/chrome-update.xml" 56 | } 57 | } 58 | } 59 | ``` 60 | 61 | Chrome Update (`chrome-update.xml`) file: 62 | 63 | ```xml 64 | 65 | 66 | 67 | 68 | 69 | ``` 70 | 71 | ## Configuration 72 | 73 | We implement the `storage.managed` webextension API to allow injection of configuration. 74 | By that, a system administrator can configure settings of the extension via the policy files. 75 | 76 | > [!NOTE] 77 | > Some settings cannot be automatically enabled (e.g., granting permissions), as they require 78 | > user interaction. In this case, the extension detects a configuration update and notifies 79 | > the user via the tray icon. The user can then apply the changes by clicking a link in the 80 | > tray menu. 81 | 82 | ### Managed settings 83 | 84 | The settings are added to the policy file under `3rdparty.extensions.`. Example: 85 | 86 | 87 | ```json 88 | { 89 | "3rdparty": { 90 | "extensions": { 91 | "linux-entra-sso@example.com": { 92 | "wellKnownApps": { 93 | "example.com": true, 94 | } 95 | } 96 | } 97 | } 98 | } 99 | ``` 100 | 101 | #### `wellKnownApps` 102 | 103 | To allow background SSO, the extension needs the `host_permissions` for both the application 104 | domain, as well as for the login provider. Hereby, the app domain can be anything, but is 105 | usually known to the company managing the devices. 106 | 107 | Value: Dictionary of key-value pairs (string, bool), where the key is the domain and the 108 | value denotes if SSO is enabled. The domain must precisely match. Wildcards are not (yet) 109 | supported. 110 | 111 | ```json 112 | { 113 | "wellKnownApps": { 114 | "example.com": true, 115 | "another.example.com": false 116 | } 117 | } 118 | ``` 119 | -------------------------------------------------------------------------------- /.pages/firefox/updates.json: -------------------------------------------------------------------------------- 1 | { 2 | "addons": { 3 | "linux-entra-sso@example.com": { 4 | "updates": [ 5 | { 6 | "version": "0.6.1", 7 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v0.6.1/linux_entra_sso-0.6.1.xpi", 8 | "update_hash": "sha256:edb6d8b754d4c2390517815ed72f5a96f1394f322a555cf54f477ca5190562c1" 9 | }, 10 | { 11 | "version": "0.7", 12 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v0.7/linux_entra_sso-0.7.xpi", 13 | "update_hash": "sha256:52cf7b7156dbcf28b7e3618fed0d904d6d2d422314ba252993a421e5ffcc165f" 14 | }, 15 | { 16 | "version": "0.8", 17 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v0.8/linux_entra_sso-0.8.xpi", 18 | "update_hash": "sha256:fec76078c4bd89505cdaef8dc039ec2b2af8ee2e8a097d0c0e0d3983fbc5f9ce" 19 | }, 20 | { 21 | "version": "0.9", 22 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v0.9/linux_entra_sso-0.9.xpi", 23 | "update_hash": "sha256:dc44b695a83c0b8adf92f0fe3c60aa1c01dc0df9e521e262bdf2c7a576720d50" 24 | }, 25 | { 26 | "version": "1.0", 27 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.0/linux_entra_sso-1.0.xpi", 28 | "update_hash": "sha256:6f57198cd5dde85967dc7c5a3a27d421f66e3acb74a7bd3ea5a1cdaf65976b04" 29 | }, 30 | { 31 | "version": "1.1.0", 32 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.1.0/linux_entra_sso-1.1.0.xpi", 33 | "update_hash": "sha256:db046d1c0f59990be46111d03d5803c477f45e3a70df93fe68edd72fe54cd303" 34 | }, 35 | { 36 | "version": "1.2.0", 37 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.2.0/linux_entra_sso-1.2.0.xpi", 38 | "update_hash": "sha256:33350336b7f049ff38065050b6f91563ecaff331e0c31f60796162a4ae56c156" 39 | }, 40 | { 41 | "version": "1.3.0", 42 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.3.0/linux_entra_sso-1.3.0.xpi", 43 | "update_hash": "sha256:29168cdef8001059c996d918e7fb6c27e3a1d569802a6b4f1bb96221a947ced5" 44 | }, 45 | { 46 | "version": "1.3.1", 47 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.3.1/linux_entra_sso-1.3.1.xpi", 48 | "update_hash": "sha256:4d920d20d39292e7335ffdfbc04603b0d286eb3de980699fa4c1648104b543fb" 49 | }, 50 | { 51 | "version": "1.4.0", 52 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.4.0/linux_entra_sso-1.4.0.xpi", 53 | "update_hash": "sha256:5575d57b7c002cb02286754fe4855a15dda608f5c46026e68c1609fb220e1df1" 54 | }, 55 | { 56 | "version": "1.5.0", 57 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.5.0/linux_entra_sso-1.5.0.xpi", 58 | "update_hash": "sha256:9efa88c1978b6aca3766c5e6dba923f9dd3f89f1a43326723ebc4103d5cf318e" 59 | }, 60 | { 61 | "version": "1.5.1", 62 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.5.1/linux_entra_sso-1.5.1.xpi", 63 | "update_hash": "sha256:7e2d38a08a6da22e63340a284e76e9a6bcba8fd74c81a576e52952cd24372ca8" 64 | }, 65 | { 66 | "version": "1.6.0", 67 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.6.0/linux_entra_sso-1.6.0.xpi", 68 | "update_hash": "sha256:9e18e4aa5482ca128f7229b0699cbe0c0f0d91454718e9656d7433a69c99fe09" 69 | }, 70 | { 71 | "version": "1.7.0", 72 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.7.0/linux_entra_sso-1.7.0.xpi", 73 | "update_hash": "sha256:d425866f65b5fc70082cb8952e9f9b806914fc88a4d0ccaa89ad19c5b1f59c60" 74 | }, 75 | { 76 | "version": "1.7.1", 77 | "update_link": "https://github.com/siemens/linux-entra-sso/releases/download/v1.7.1/linux_entra_sso-1.7.1.xpi", 78 | "update_hash": "sha256:a1ad90f08b25e3bf732405647379e06e15262b77cefd24d46a8936f4ff69e84e" 79 | } 80 | ] 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /popup/menu.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 | Avatar 17 |
18 |
19 |
Guest
20 | 21 | 22 |
23 |
24 | 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /tests/linux_entra_sso_mock.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: MPL-2.0 3 | # SPDX-FileCopyrightText: Copyright 2025 Siemens AG 4 | """ 5 | Mock implementation of the native part to test the web extension 6 | without having a broker. 7 | """ 8 | 9 | import importlib 10 | import sys 11 | import time 12 | import jwt 13 | 14 | 15 | les = importlib.import_module("linux-entra-sso") 16 | 17 | 18 | class SsoMibMock(les.SsoMib): 19 | """ 20 | Implementation of the SsoMib without broker communication. 21 | """ 22 | 23 | # random but stable 24 | MOCK_TENANT = "f52f0148-c8bb-4ee1-899b-8f93b0e4d63d" 25 | 26 | def __init__(self, daemon=False): # pylint: disable=unused-argument 27 | self.broker = True 28 | 29 | def on_broker_state_changed(self, callback): # pylint: disable=unused-argument 30 | """ 31 | We do not implement state changes yet. 32 | """ 33 | 34 | def get_accounts(self): 35 | """ 36 | Returns two fake accounts with otherwise valid data. 37 | """ 38 | return { 39 | "accounts": [ 40 | { 41 | "name": "Account, Test (My Org Code)", 42 | "givenName": "Account, Test (My Org Code)", 43 | "username": "test.account@my-org.example.com", 44 | "homeAccountId": f"{self.MOCK_TENANT}-a975168d-a362-458b-af1c-a8982b1e8aac", 45 | "localAccountId": "a975168d-a362-458b-af1c-a8982b1e8aac", 46 | "clientInfo": jwt.encode( 47 | {"some": "payload"}, "secret", algorithm="HS256" 48 | ).split(".", maxsplit=1)[0], 49 | "realm": self.MOCK_TENANT, 50 | }, 51 | { 52 | "name": "Account, Admin (My Org Code)", 53 | "givenName": "Account, Admin (My Org Code)", 54 | "username": "test.admin@my-org.example.com", 55 | "homeAccountId": f"{self.MOCK_TENANT}-2f205376-88f7-47a4-be93-8aa7cae8e4fa", 56 | "localAccountId": "2f205376-88f7-47a4-be93-8aa7cae8e4fa", 57 | "clientInfo": jwt.encode( 58 | {"some": "payload"}, "secret", algorithm="HS256" 59 | ).split(".", maxsplit=1)[0], 60 | "realm": self.MOCK_TENANT, 61 | }, 62 | ] 63 | } 64 | 65 | def acquire_prt_sso_cookie( 66 | self, account, sso_url, scopes=les.SsoMib.GRAPH_SCOPES 67 | ): # pylint: disable=dangerous-default-value,unused-argument 68 | """ 69 | Return a fake PRT SSO Cookie. The returned data cannot be used to perform SSO. 70 | """ 71 | return { 72 | "account": account, 73 | "cookieContent": jwt.encode( 74 | {"scopes": " ".join(scopes)}, "secret", algorithm="HS256" 75 | ), 76 | "cookieName": "x-ms-RefreshTokenCredential", 77 | } 78 | 79 | def acquire_token_silently( 80 | self, account, scopes=les.SsoMib.GRAPH_SCOPES 81 | ): # pylint: disable=dangerous-default-value 82 | """ 83 | Return a fake (invalid) token. 84 | """ 85 | return { 86 | "brokerTokenResponse": { 87 | "accessToken": jwt.encode( 88 | {"scopes": " ".join(scopes)}, "secret", algorithm="HS256" 89 | ), 90 | "accessTokenType": 0, 91 | "idToken": jwt.encode( 92 | {"scopes": " ".join(scopes)}, "secret", algorithm="HS256" 93 | ), 94 | "account": account, 95 | "clientInfo": account["clientInfo"], 96 | "expiresOn": int(time.time() + 3600) * 1000, 97 | "extendedExpiresOn": int(time.time() + 2 * 3600) * 1000, 98 | "grantedScopes": scopes + ["profile"], 99 | } 100 | } 101 | 102 | def get_broker_version(self): 103 | """ 104 | Return the broker and script version (marked as mock). 105 | """ 106 | return { 107 | "linuxBrokerVersion": "2.0.1-mock", 108 | "native": f"{les.LINUX_ENTRA_SSO_VERSION}-mock", 109 | } 110 | 111 | 112 | les.SsoMib = SsoMibMock 113 | 114 | if __name__ == "__main__": 115 | if "--interactive" in sys.argv or "-i" in sys.argv: 116 | les.run_interactive() 117 | else: 118 | les.run_as_native_messaging() 119 | -------------------------------------------------------------------------------- /src/broker.js: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: MPL-2.0 3 | * SPDX-FileCopyrightText: Copyright 2025 Siemens 4 | */ 5 | 6 | import { ssoLog, ssoLogError, Deferred } from "./utils.js"; 7 | import { Account } from "./account.js"; 8 | 9 | /** 10 | * Queue to resolve promises, once the data arrives from the 11 | * remote backend. 12 | */ 13 | export class RpcHandlerQueue { 14 | #queue = []; 15 | 16 | register_handle(id) { 17 | let handle = { 18 | id: id, 19 | dfd: new Deferred(), 20 | }; 21 | this.#queue.push(handle); 22 | return handle.dfd.promise; 23 | } 24 | 25 | resolve_handle(id, data) { 26 | let idx = this.#queue.findIndex((hdl) => hdl.id == id); 27 | if (idx !== -1) { 28 | this.#queue[idx].dfd.resolve(data); 29 | this.#queue.splice(idx, 1); 30 | } 31 | } 32 | 33 | reject_handle(id, data) { 34 | let idx = this.#queue.findIndex((hdl) => hdl.id == id); 35 | if (idx !== -1) { 36 | this.#queue[idx].dfd.reject(data); 37 | this.#queue.splice(idx, 1); 38 | } 39 | } 40 | } 41 | 42 | export class Broker { 43 | #name = null; 44 | #notify_fn = null; 45 | #port_native = null; 46 | #rpc_queue = new RpcHandlerQueue(); 47 | #online = false; 48 | 49 | constructor(name, state_change_fn) { 50 | this.#name = name; 51 | this.#notify_fn = state_change_fn; 52 | } 53 | 54 | connect() { 55 | this.#port_native = chrome.runtime.connectNative(this.#name); 56 | this.#port_native.onDisconnect.addListener(() => { 57 | this.#port_native = null; 58 | if (chrome.runtime.lastError) { 59 | ssoLogError( 60 | "Error in native application connection: " + 61 | chrome.runtime.lastError.message, 62 | ); 63 | } else { 64 | ssoLogError("Native application connection closed."); 65 | } 66 | this.#notify_fn(false); 67 | }); 68 | this.#port_native.onMessage.addListener( 69 | this.#on_message_native.bind(this), 70 | ); 71 | ssoLog("Broker created"); 72 | } 73 | 74 | isConnected() { 75 | return this.#port_native !== null; 76 | } 77 | 78 | isRunning() { 79 | return this.#online; 80 | } 81 | 82 | getAccounts() { 83 | this.#port_native.postMessage({ command: "getAccounts" }); 84 | return this.#rpc_queue.register_handle("getAccounts"); 85 | } 86 | 87 | async acquireTokenSilently(account) { 88 | this.#port_native.postMessage({ 89 | command: "acquireTokenSilently", 90 | account: account.brokerObject(), 91 | }); 92 | return this.#rpc_queue.register_handle("acquireTokenSilently"); 93 | } 94 | 95 | async acquirePrtSsoCookie(account, ssoUrl) { 96 | this.#port_native.postMessage({ 97 | command: "acquirePrtSsoCookie", 98 | account: account.brokerObject(), 99 | ssoUrl: ssoUrl, 100 | }); 101 | return this.#rpc_queue.register_handle("acquirePrtSsoCookie"); 102 | } 103 | 104 | async getVersion() { 105 | this.#port_native.postMessage({ command: "getVersion" }); 106 | return this.#rpc_queue.register_handle("getVersion"); 107 | } 108 | 109 | #on_message_native(response) { 110 | if (response.command == "acquirePrtSsoCookie") { 111 | this.#rpc_queue.resolve_handle("acquirePrtSsoCookie", { 112 | cookieName: response.message.cookieName, 113 | cookieContent: response.message.cookieContent, 114 | }); 115 | } else if (response.command == "getAccounts") { 116 | if ("error" in response.message) { 117 | this.#rpc_queue.reject_handle("getAccounts", { 118 | ...response.message.error, 119 | }); 120 | } else { 121 | let _accounts = []; 122 | for (const a of response.message.accounts) { 123 | _accounts.push(new Account(a)); 124 | } 125 | this.#rpc_queue.resolve_handle("getAccounts", _accounts); 126 | } 127 | } else if (response.command == "getVersion") { 128 | this.#rpc_queue.resolve_handle("getVersion", { 129 | native: response.message.native, 130 | broker: response.message.linuxBrokerVersion, 131 | }); 132 | } else if (response.command == "acquireTokenSilently") { 133 | if ("error" in response.message) { 134 | this.#rpc_queue.reject_handle("acquireTokenSilently", { 135 | ...response.message.error, 136 | }); 137 | } else { 138 | this.#rpc_queue.resolve_handle("acquireTokenSilently", { 139 | ...response.message.brokerTokenResponse, 140 | }); 141 | } 142 | } else if (response.command == "brokerStateChanged") { 143 | /* event (not an RPC response) */ 144 | if (response.message == "online") this.#online = true; 145 | else this.#online = false; 146 | this.#notify_fn(this.#online); 147 | } else { 148 | ssoLog("unknown command: " + response.command); 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /popup/menu.css: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: MPL-2.0 3 | * SPDX-FileCopyrightText: Copyright 2024 Siemens AG 4 | */ 5 | 6 | :root { 7 | --font-size: 14px; 8 | --font-size-smaller: calc(var(--font-size) - 1px); 9 | --font-size-xsmall: calc(var(--font-size) - 3px); 10 | --font-size-larger: 15px; 11 | --font-family: Arial, Helvetica, Sans-Serif; 12 | --monospace-size: 12px; 13 | } 14 | 15 | /* 16 | * Colors from https://protocol.mozilla.org/docs/fundamentals/color.html 17 | */ 18 | 19 | :root { 20 | --light-gray-05: #ffffff; 21 | --light-gray-10: #f9f9fb; 22 | --light-gray-20: #f0f0f4; 23 | --light-gray-30: #e0e0e6; 24 | --light-gray-40: #cfcfd8; 25 | --light-gray-50: #bfbfc9; 26 | --dark-gray-05: #5b5b66; 27 | --dark-gray-20: #4a4a55; 28 | --dark-gray-40: #3a3944; 29 | --dark-gray-90: #15141a; 30 | --red-40: #ff6a75; 31 | --red-70: #c50042; 32 | --green-40: #54ffbd; 33 | --green-70: #008787; 34 | --orange-40: #ff8a50; 35 | --orange-70: #cc3d00; 36 | } 37 | 38 | /* set color scheme before JS overwrites it to avoid flickering of white page on dark default */ 39 | @media (prefers-color-scheme: light) { 40 | :root { 41 | --surface-0: var(--light-gray-05); 42 | --surface-1: var(--light-gray-20); 43 | --surface-2: var(--light-gray-40); 44 | } 45 | } 46 | @media (prefers-color-scheme: dark) { 47 | :root { 48 | --surface-0: var(--dark-gray-90); 49 | --surface-1: var(--dark-gray-40); 50 | --surface-2: var(--dark-gray-20); 51 | } 52 | } 53 | 54 | :root.light { 55 | --surface-0: var(--light-gray-05); 56 | --surface-1: var(--light-gray-20); 57 | --surface-2: var(--light-gray-40); 58 | --border-0: var(--light-gray-30); 59 | --text-0: var(--dark-gray-90); 60 | --text-1: var(--dark-gray-05); 61 | --text-red: var(--red-70); 62 | --text-green: var(--green-70); 63 | --text-orange: var(--orange-70); 64 | } 65 | 66 | :root.dark { 67 | --surface-0: var(--dark-gray-90); 68 | --surface-1: var(--dark-gray-40); 69 | --surface-2: var(--dark-gray-20); 70 | --border-0: var(--dark-gray-05); 71 | --text-0: var(--light-gray-05); 72 | --text-1: var(--light-gray-50); 73 | --text-red: var(--red-40); 74 | --text-green: var(--green-40); 75 | --text-orange: var(--orange-40); 76 | } 77 | 78 | html, 79 | body { 80 | width: 420px; 81 | padding: 0; 82 | margin: 0; 83 | } 84 | 85 | body { 86 | background-color: var(--surface-0); 87 | font-family: var(--font-family); 88 | } 89 | 90 | .entity { 91 | padding: 10px; 92 | margin: 10px; 93 | border-radius: 15px; 94 | color: var(--text-1); 95 | cursor: pointer; 96 | display: flow-root; 97 | } 98 | 99 | .info-no-trust { 100 | display: none; 101 | } 102 | body.has-account .info-no-trust { 103 | display: inline-block; 104 | } 105 | body.has-account .info-no-account { 106 | display: none; 107 | } 108 | 109 | body.pending .entity { 110 | cursor: wait; 111 | } 112 | 113 | body.nm-connected #nm-error-box { 114 | display: none; 115 | } 116 | 117 | .entity:hover { 118 | background-color: var(--surface-1); 119 | } 120 | 121 | .entity.active { 122 | background-color: var(--surface-2); 123 | color: var(--text-0); 124 | cursor: default; 125 | } 126 | 127 | .entity .avatar { 128 | float: left; 129 | margin-right: 10px; 130 | } 131 | 132 | .entity .info .name { 133 | font-size: var(--font-size-larger); 134 | } 135 | .entity .info .email { 136 | font-size: var(--font-size-smaller); 137 | } 138 | 139 | .entity .avatar img { 140 | width: 48px; 141 | height: 48px; 142 | } 143 | 144 | .state-connected-icon { 145 | color: var(--text-red); 146 | } 147 | .connected .state-connected-icon { 148 | color: var(--text-green); 149 | } 150 | 151 | .state-compliant-icon { 152 | color: var(--text-red); 153 | } 154 | .compliant .state-compliant-icon { 155 | color: var(--text-green); 156 | } 157 | 158 | .hidden { 159 | display: none !important; 160 | } 161 | 162 | .footer { 163 | padding-bottom: 5px; 164 | font-size: var(--font-size-smaller); 165 | min-height: 1em; 166 | color: var(--text-1); 167 | } 168 | 169 | .footer > .footer-block { 170 | border-top: 1px solid var(--border-0); 171 | padding-top: 5px; 172 | padding-bottom: 5px; 173 | margin: 0px 5px 0px 5px; 174 | } 175 | 176 | .footer > .footer-block > div { 177 | clear: both; 178 | } 179 | 180 | .footer a, 181 | .footer a:visited { 182 | color: var(--text-1); 183 | } 184 | 185 | .footer .right { 186 | float: right; 187 | } 188 | 189 | .footer .clear { 190 | clear: both; 191 | } 192 | 193 | .error-text { 194 | color: var(--text-red); 195 | } 196 | .footer .error-text a, 197 | .footer .error-text a:visited { 198 | color: var(--text-red); 199 | } 200 | 201 | .warning-text { 202 | color: var(--text-orange); 203 | } 204 | 205 | span.link { 206 | text-decoration: underline dotted; 207 | cursor: pointer; 208 | } 209 | 210 | #withdraw-access { 211 | display: none; 212 | } 213 | .connected #grant-access { 214 | display: none; 215 | } 216 | .connected #withdraw-access { 217 | display: inline-block; 218 | } 219 | 220 | #bg-sso-state.immutable .link { 221 | display: none; 222 | } 223 | /* show the immutable texts only when immutable */ 224 | #bg-sso-state .info-text-immutable { 225 | display: none; 226 | } 227 | #bg-sso-state.immutable .info-text-immutable.disabled { 228 | display: inline-block; 229 | } 230 | #bg-sso-state.immutable.connected .info-text-immutable.disabled { 231 | display: none; 232 | } 233 | #bg-sso-state.immutable.connected .info-text-immutable.enabled { 234 | display: inline-block; 235 | } 236 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright 2024 Siemens AG 2 | # 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | name: release browser extension 6 | 7 | on: 8 | push: 9 | tags: 10 | - 'v*.*' 11 | - 'v*.*.*' 12 | 13 | permissions: {} 14 | 15 | env: 16 | WEB_EXT_VERS: 8.9.0 17 | 18 | jobs: 19 | release-extension: 20 | permissions: 21 | contents: write 22 | pull-requests: write 23 | id-token: write 24 | attestations: write 25 | runs-on: ubuntu-24.04 26 | steps: 27 | - name: checkout repository 28 | uses: actions/checkout@v4 29 | 30 | - name: get git committer 31 | run: | 32 | echo "GIT_COMMITTER=$(git show -s --format='%cn <%ce>')" >> $GITHUB_ENV 33 | 34 | - name: install dependencies 35 | run: | 36 | sudo apt-get update && sudo apt-get install -y make git zip 37 | 38 | - name: build packages 39 | run: RELEASE_TAG=${{ github.ref_name }} make package deb 40 | 41 | - name: sign Firefox extension by Mozilla 42 | run: | 43 | npx web-ext@${{ env.WEB_EXT_VERS }} sign \ 44 | --channel unlisted \ 45 | --approval-timeout 900000 \ 46 | --api-key ${{ secrets.AMO_API_KEY }} \ 47 | --api-secret ${{ secrets.AMO_API_SECRET }} \ 48 | --source-dir build/firefox \ 49 | --artifacts-dir build 50 | 51 | # self distributed extensions on Thunderbird are not signed 52 | - name: build Thunderbird extension 53 | run: | 54 | npx web-ext@${{ env.WEB_EXT_VERS }} build \ 55 | --source-dir build/thunderbird \ 56 | --artifacts-dir build \ 57 | --filename '{name}-{version}.thunderbird.xpi' 58 | 59 | - name: upload firefox extension 60 | uses: actions/upload-artifact@v4 61 | with: 62 | name: firefox-signed-xpi 63 | path: | 64 | build/linux_entra_sso-*.xpi 65 | 66 | - name: upload debian package 67 | uses: actions/upload-artifact@v4 68 | with: 69 | name: debian-package 70 | path: | 71 | pkgs/linux-entra-sso_*.deb 72 | 73 | - name: attest Firefox extension build 74 | uses: actions/attest-build-provenance@v1 75 | with: 76 | subject-path: | 77 | build/linux_entra_sso-*.xpi 78 | pkgs/linux-entra-sso_*.deb 79 | 80 | - name: create release 81 | uses: softprops/action-gh-release@a74c6b72af54cfa997e81df42d94703d6313a2d0 # v2.0.6 82 | with: 83 | files: | 84 | build/linux_entra_sso-*.xpi 85 | pkgs/linux-entra-sso_*.deb 86 | token: ${{ secrets.GITHUB_TOKEN }} 87 | body: | 88 | Release of version ${{ github.ref_name }} 89 | tag_name: ${{ github.ref_name }} 90 | draft: false 91 | prerelease: true 92 | 93 | - name: create update manifest for Firefox 94 | run: | 95 | VERSION=$(echo ${{ github.ref_name }} | cut -c 2-) 96 | DIGEST="sha256:$(sha256sum build/linux_entra_sso-${VERSION}.xpi | cut -d ' ' -f 1)" 97 | LINK="https://github.com/siemens/linux-entra-sso/releases/download/v${VERSION}/linux_entra_sso-${VERSION}.xpi" 98 | jq --arg version "${VERSION}" --arg digest "${DIGEST}" --arg link "${LINK}" \ 99 | '."addons"."linux-entra-sso@example.com"."updates" += [{"version":$version, "update_link":$link, "update_hash":$digest}]' \ 100 | .pages/firefox/updates.json \ 101 | > .pages/firefox/updates.json.tmp && mv .pages/firefox/updates.json.tmp .pages/firefox/updates.json 102 | 103 | - name: create update manifest for Thunderbird 104 | run: | 105 | VERSION=$(echo ${{ github.ref_name }} | cut -c 2-) 106 | DIGEST="sha256:$(sha256sum build/linux_entra_sso-${VERSION}.thunderbird.xpi | cut -d ' ' -f 1)" 107 | LINK="https://github.com/siemens/linux-entra-sso/releases/download/v${VERSION}/linux_entra_sso-${VERSION}.thunderbird.xpi" 108 | jq --arg version "${VERSION}" --arg digest "${DIGEST}" --arg link "${LINK}" \ 109 | '."addons"."@linux-entra-sso.tb"."updates" += [{"version":$version, "update_link":$link, "update_hash":$digest}]' \ 110 | .pages/thunderbird/updates.json \ 111 | > .pages/thunderbird/updates.json.tmp && mv .pages/thunderbird/updates.json.tmp .pages/thunderbird/updates.json 112 | 113 | - name: prepare PR for Mozilla update manifests 114 | uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f # v7.0.6 115 | with: 116 | add-paths: | 117 | .pages/firefox/updates.json 118 | .pages/thunderbird/updates.json 119 | commit-message: "chore: release Mozilla update manifests" 120 | branch: ci/release-firefox-update-manifest 121 | base: main 122 | title: "chore: release Mozilla update manifests [bot]" 123 | assignees: fmoessbauer 124 | reviewers: jan-kiszka 125 | author: ${{ env.GIT_COMMITTER }} 126 | committer: ${{ env.GIT_COMMITTER }} 127 | signoff: true 128 | body: | 129 | Publish update manifest for Mozilla extensions, version ${{ github.ref_name }}. 130 | 131 | - name: release Chrome extension on CWS 132 | uses: mnao305/chrome-extension-upload@4008e29e13c144d0f6725462cbd49b7c291b4928 # v5.0.0 133 | with: 134 | file-path: build/Linux-Entra-SSO-*chrome.zip 135 | glob: true 136 | extension-id: jlnfnnolkbjieggibinobhkjdfbpcohn 137 | client-id: ${{ secrets.CWS_CLIENT_ID }} 138 | client-secret: ${{ secrets.CWS_CLIENT_SECRET }} 139 | refresh-token: ${{ secrets.CWS_REFRESH_TOKEN }} 140 | publish: false 141 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: MPL-2.0 3 | * SPDX-FileCopyrightText: Copyright 2024 Siemens AG 4 | */ 5 | 6 | import { create_platform } from "./platform-factory.js"; 7 | import { Broker } from "./broker.js"; 8 | import { AccountManager } from "./account.js"; 9 | import { ssoLog } from "./utils.js"; 10 | import { PolicyManager } from "./policy.js"; 11 | import { Device, DeviceManager } from "./device.js"; 12 | 13 | const PLATFORM = create_platform(); 14 | let broker = null; 15 | let policyManager = null; 16 | let accountManager = null; 17 | let deviceManager = null; 18 | 19 | let initialized = false; 20 | let state_active = true; 21 | let port_menu = null; 22 | 23 | /* 24 | * Check if all conditions for SSO are met 25 | */ 26 | function is_operational() { 27 | return state_active && accountManager.getActive(); 28 | } 29 | 30 | async function on_permissions_changed() { 31 | ssoLog("permissions changed, reload host_permissions"); 32 | await PLATFORM.update_host_permissions(); 33 | notify_state_change(); 34 | } 35 | 36 | /* 37 | * Update the UI according to the current state 38 | */ 39 | async function update_tray(action_needed) { 40 | chrome.action.enable(); 41 | if (is_operational()) { 42 | let account = accountManager.getActive(); 43 | let imgdata = {}; 44 | let icon_title = account.username(); 45 | 46 | // shorten the title a bit 47 | if (PLATFORM.browser == "Thunderbird") 48 | icon_title = icon_title.split("@")[0]; 49 | 50 | let color = null; 51 | chrome.action.setTitle({ 52 | title: icon_title, 53 | }); 54 | if (!broker.isRunning()) { 55 | color = "#cc0000"; 56 | } 57 | for (const r of [16, 32, 48]) { 58 | imgdata[r] = await account.getDecoratedAvatar(color, r); 59 | } 60 | chrome.action.setIcon({ 61 | imageData: imgdata, 62 | }); 63 | chrome.action.setBadgeText({ 64 | text: action_needed ? "1" : null, 65 | }); 66 | return; 67 | } 68 | /* inactive states */ 69 | PLATFORM.setIconDisabled(); 70 | let title = "EntraID SSO disabled"; 71 | if (state_active) title = "EntraID SSO disabled (waiting for broker)"; 72 | if (accountManager.hasAccounts() == 0) { 73 | title = "EntraID SSO disabled (no accounts registered)"; 74 | } 75 | if (!broker.isConnected()) { 76 | title = "EntraID SSO disabled (no connection to host application)"; 77 | chrome.action.setBadgeText({ 78 | text: "1", 79 | }); 80 | } 81 | // We have limited space on Thunderbird, hence shorten the title 82 | if (PLATFORM.browser == "Thunderbird") title = "EntraID SSO disabled"; 83 | chrome.action.setTitle({ title: title }); 84 | } 85 | 86 | /* 87 | * Update the tray icon, (un)register the handlers and notify 88 | * the menu about a state change. 89 | */ 90 | function notify_state_change(ui_only = false) { 91 | const gpo_update = policyManager.getPolicyUpdate( 92 | PLATFORM.well_known_app_filters, 93 | ); 94 | let action_needed = !PLATFORM.sso_url_permitted || gpo_update.pending; 95 | update_tray(action_needed); 96 | if (!ui_only && broker.isConnected()) { 97 | ssoLog("update handlers"); 98 | PLATFORM.update_request_handlers( 99 | is_operational(), 100 | accountManager.getActive(), 101 | broker, 102 | ); 103 | } 104 | if (port_menu === null) return; 105 | deviceManager.updateDeviceInfo().then((updated) => { 106 | if (updated) { 107 | notify_state_change(true); 108 | } 109 | }); 110 | port_menu.postMessage({ 111 | event: "stateChanged", 112 | accounts: accountManager.getRegistered().map((a) => a.toMenuObject()), 113 | broker_online: broker.isRunning(), 114 | nm_connected: broker.isConnected(), 115 | device: deviceManager.getDevice(), 116 | enabled: state_active, 117 | host_version: PLATFORM.host_versions.native, 118 | broker_version: PLATFORM.host_versions.broker, 119 | sso_url: PLATFORM.getSsoUrl(), 120 | gpo_update: gpo_update, 121 | }); 122 | } 123 | 124 | async function on_message_menu(request) { 125 | if (request.command == "enable") { 126 | state_active = true; 127 | const account = accountManager.selectAccount(request.username); 128 | if (account) ssoLog("select account " + account.username()); 129 | } else if (request.command == "disable") { 130 | state_active = false; 131 | accountManager.logout(); 132 | ssoLog("disable SSO"); 133 | } 134 | accountManager.persist(); 135 | notify_state_change(); 136 | } 137 | 138 | async function on_broker_state_change(online) { 139 | if (online) { 140 | ssoLog("connection to broker restored"); 141 | // only reload data if we did not see the broker before 142 | if (!accountManager.hasBrokerData()) { 143 | await accountManager.loadAccounts(); 144 | accountManager.persist(); 145 | await deviceManager.loadDeviceInfo(); 146 | notify_state_change(); 147 | } 148 | } else { 149 | ssoLog("lost connection to broker"); 150 | } 151 | notify_state_change(true); 152 | } 153 | 154 | async function on_storage_changed(changes, areaName) { 155 | if (areaName == "managed") { 156 | await policyManager.load_policies(); 157 | } 158 | } 159 | 160 | function on_startup() { 161 | if (initialized) { 162 | ssoLog("linux-entra-sso already initialized"); 163 | return; 164 | } 165 | initialized = true; 166 | ssoLog("start linux-entra-sso on " + PLATFORM.browser); 167 | policyManager = new PolicyManager(); 168 | 169 | chrome.storage.onChanged.addListener(on_storage_changed); 170 | chrome.permissions.onAdded.addListener(on_permissions_changed); 171 | chrome.permissions.onRemoved.addListener(on_permissions_changed); 172 | 173 | broker = new Broker("linux_entra_sso", on_broker_state_change); 174 | accountManager = new AccountManager(broker); 175 | deviceManager = new DeviceManager(accountManager); 176 | Promise.all([ 177 | PLATFORM.update_host_permissions(), 178 | policyManager.load_policies(), 179 | accountManager.restore().then((active) => { 180 | state_active = active; 181 | }), 182 | ]).then(() => { 183 | broker.connect(); 184 | PLATFORM.setup(broker).then(() => { 185 | notify_state_change(true); 186 | }); 187 | notify_state_change(); 188 | }); 189 | 190 | chrome.runtime.onConnect.addListener((port) => { 191 | port_menu = port; 192 | port_menu.onMessage.addListener(on_message_menu); 193 | port_menu.onDisconnect.addListener(() => { 194 | port_menu = null; 195 | }); 196 | notify_state_change(true); 197 | }); 198 | } 199 | 200 | // use this API to prevent the extension from being disabled 201 | chrome.runtime.onStartup.addListener(on_startup); 202 | 203 | on_startup(); 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | # Entra ID SSO via Microsoft Identity Broker on Linux 7 | 8 | This browser extension uses a locally running Microsoft Identity Broker to authenticate the current user on Microsoft Entra ID on Linux devices. 9 | By that, also sites behind conditional access policies can be accessed. 10 | The extension is written for Firefox but provides a limited support for Google Chrome, Chromium and Thunderbird. 11 | 12 | > [!NOTE] 13 | > This extension will only work on intune-enabled Linux devices. Please double 14 | > check this by running the `intune-portal` application and check if your user 15 | > is logged in (after clicking `sign-in`). 16 | 17 | ## Installation 18 | 19 | The extension consists of two parts: 20 | 21 | - a host program that communicates with the Microsoft Identity Broker via DBus 22 | - a WebExtension that injects the acquired tokens into the corresponding requests 23 | 24 | ### Dependencies 25 | 26 | The extension requires [PyGObject](https://pygobject.gnome.org/) and [pydbus](https://github.com/LEW21/pydbus) as runtime dependencies. 27 | 28 | - On Debian: `sudo apt-get install python3-gi python3-pydbus` 29 | - On Arch Linux: `sudo pacman -S python-gobject python-pydbus` 30 | - If you are using a Python version manager such as `asdf` you must install the Python packages manually: `pip install PyGObject pydbus` 31 | 32 | ### Installation of Host Tooling 33 | 34 | 1. Clone this repository: 35 | 36 | ```bash 37 | $ git clone https://github.com/siemens/linux-entra-sso.git 38 | $ cd linux-entra-sso 39 | ``` 40 | 41 | 2. Run the local install command (for the intended target): 42 | 43 | ```bash 44 | $ # Firefox & Thunderbird 45 | $ make local-install-firefox 46 | $ # Chromium, Chrome and Brave 47 | $ make local-install-chrome 48 | $ # All supported browsers 49 | $ make local-install 50 | ``` 51 | 52 | > [!NOTE] 53 | > System-wide installation and configuration is supported. For more information, see [Global Install](docs/global_install.md). 54 | 55 | ### Installation of WebExtension 56 | 57 | To complete the setup, install the WebExtension in your browser. This is necessary alongside the host tooling for the extension to function properly. 58 | 59 | **Firefox & Thunderbird: Signed Version from GitHub Releases**: 60 | Install the signed webextension `linux_entra_sso-.xpi` from the [project's releases page](https://github.com/siemens/linux-entra-sso/releases). 61 | If you are installing for Thunderbird, right-click the link and select "Save Link As..." to avoid installing it in Firefox. 62 | 63 | **Chromium, Chrome & Brave: Signed Extension from Chrome Web Store**: 64 | Install the signed browser extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/jlnfnnolkbjieggibinobhkjdfbpcohn). 65 | 66 | **Development Version and Other Browsers**: 67 | If you want to execute unsigned versions of the extension (e.g. test builds) on Firefox, you have to use either Firefox ESR, 68 | nightly or developer, as [standard Firefox does not allow installing unsigned extensions](https://support.mozilla.org/en-US/kb/add-on-signing-in-firefox#w_what-are-my-options-if-i-want-to-use-an-unsigned-add-on-advanced-users) 69 | since version 48. 70 | 71 | To build the extension, perform the following steps: 72 | 73 | 1. run `make` to build the extension (For Firefox, `build//linux-entra-sso-*.xpi` is generated) 74 | 2. Firefox only: Permit unsigned extensions in Firefox by setting `xpinstall.signatures.required` to `false` 75 | 3. Chrome only: In extension menu, enable `Developer mode`. 76 | 4. Install the extension in the Browser from the local `linux-entra-sso-*.xpi` file (Firefox). On Chrome, use `load unpacked` and point to `build/chrome` 77 | 78 | ## Usage 79 | 80 | After installing the extension, you might need to manually grant the following permission: 81 | 82 | - Access your data for `https://login.microsoftonline.com`. 83 | 84 | **No configuration is required.** The SSO is automatically enabled. 85 | If you want to disable the SSO for this session, click on the tray icon and select the guest account. 86 | In case you are already logged in, you might need to clear all cookies on `login.microsoftonline.com`. 87 | 88 | ### Single Page Applications 89 | 90 | For single-page applications (SPAs, like the Teams PWA) that perform automated re-logins in the background, 91 | ensure the extension has the necessary permissions to interact with the SPA's domain. 92 | Otherwise, a manual re-login after approximately 24 hours (depending on the tenant's configuration) may be required. 93 | 94 | To grant the necessary permissions, follow these steps: 95 | 96 | 1. Open the SPA URL in your web browser 97 | 2. Click on the extension's tray icon 98 | 3. Click on "Background SSO (enable)" 99 | 4. A dot should appear next to the domain indicating that permission has been granted 100 | 101 | Once configured, no further authentication requests will be needed. 102 | To revoke permissions, return to the extension's settings and select the domain again. 103 | For details, also see [PRIVACY.md](PRIVACY.md). 104 | 105 | ### Technical Background 106 | 107 | When enabled, the extension acquires a [PRT SSO Cookie](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-oapxbc/105e4d17-defd-4637-a520-173db2393a4b) 108 | from the locally running `microsoft-identity-broker` service and inject that into the OAuth2 login flow on Microsoft Entra ID (`login.microsoftonline.com`). 109 | 110 | ## Known Limitations 111 | 112 | ### Snap version of Firefox on Ubuntu 113 | 114 | Running the extension in a Snap Firefox on Ubuntu 22.04 or later is supported but requires the `xdg-desktop-portal` host package and at least Firefox 104. 115 | After installing the extension (both native and web extension part), restart the browser. 116 | When Firefox starts, a message should appear to allow Firefox to use the `WebExtension` backend. 117 | Once granted, the application should behave as on a native install. 118 | 119 | An alternative is to use the `firefox-esr` Debian package. 120 | 121 | ### Expired Tokens on Chrome 122 | 123 | Due to not having the `WebRequestsBlocking` API on Chrome, the extension needs to use a different mechanism to inject the token. 124 | While in Firefox the token is requested on-demand when hitting the SSO login URL, in Chrome the token is requested periodically. 125 | Then, a `declarativeNetRequest` API rule is setup to inject the token. As the lifetime of the tokens is limited and cannot be checked, 126 | outdated tokens might be injected. Further, a generic SSO URL must be used when requesting the token, instead of the actual one. 127 | 128 | ## Troubleshooting 129 | 130 | In case the extension is not working, check the following: 131 | 132 | - run host component in interactive mode: `python3 ./linux-entra-sso.py --interactive acquirePrtSsoCookie` 133 | - check if SSO is working in the Edge browser 134 | 135 | # Code Integrity 136 | 137 | Since version `v0.4`, git release tags are signed with one of the following maintainer GPG keys: 138 | 139 | - `AF73F6EF5A53CFE304569F50E648A311F67A50FC` (Felix Moessbauer) 140 | - `004C647D7572CF7D72BDB4FB699D850A9F417BD8` (Jan Kiszka) 141 | 142 | ## License 143 | 144 | This project is licensed according to the terms of the Mozilla Public 145 | License, v. 2.0. A copy of the license is provided in `LICENSES/MPL-2.0.txt`. 146 | -------------------------------------------------------------------------------- /src/account.js: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: MPL-2.0 3 | * SPDX-FileCopyrightText: Copyright 2025 Siemens 4 | */ 5 | 6 | import { ssoLog, load_icon, ssoLogError } from "./utils.js"; 7 | import { Deferred } from "./utils.js"; 8 | 9 | export class Account { 10 | #broker_obj = null; 11 | #avatar_imgdata = null; 12 | avatar = null; 13 | active = false; 14 | access_token = null; 15 | access_token_exp = 0; 16 | 17 | constructor(broker_obj) { 18 | this.#broker_obj = { ...broker_obj }; 19 | } 20 | 21 | name() { 22 | return this.#broker_obj.name; 23 | } 24 | 25 | username() { 26 | return this.#broker_obj.username; 27 | } 28 | 29 | brokerObject() { 30 | return this.#broker_obj; 31 | } 32 | 33 | toMenuObject() { 34 | return { 35 | name: this.name(), 36 | username: this.username(), 37 | avatar: this.avatar, 38 | active: this.active, 39 | }; 40 | } 41 | 42 | async getAvatarImgData() { 43 | if (!this.#avatar_imgdata) { 44 | this.#avatar_imgdata = await load_icon( 45 | "/icons/profile-outline_48.png", 46 | 48, 47 | ); 48 | } 49 | return this.#avatar_imgdata; 50 | } 51 | 52 | setAvatarImgData(data) { 53 | this.#avatar_imgdata = data; 54 | } 55 | 56 | async getDecoratedAvatar(color, width) { 57 | let imgdata = await this.getAvatarImgData(); 58 | const sWidth = imgdata.width; 59 | const lineWidth = Math.min(2, width / 12); 60 | let buffer = new OffscreenCanvas(sWidth, sWidth); 61 | let ctx_buffer = buffer.getContext("2d"); 62 | ctx_buffer.putImageData(imgdata, 0, 0); 63 | 64 | let canvas = new OffscreenCanvas(width, width); 65 | let ctx = canvas.getContext("2d"); 66 | ctx.save(); 67 | const img_margin = color === null ? 0 : lineWidth + 1; 68 | ctx.beginPath(); 69 | ctx.arc( 70 | width / 2, 71 | width / 2, 72 | width / 2 - img_margin, 73 | 0, 74 | Math.PI * 2, 75 | false, 76 | ); 77 | ctx.clip(); 78 | ctx.drawImage( 79 | buffer, 80 | 0, 81 | 0, 82 | sWidth, 83 | sWidth, 84 | img_margin, 85 | img_margin, 86 | width - img_margin * 2, 87 | width - img_margin * 2, 88 | ); 89 | ctx.restore(); 90 | if (color === null) { 91 | return ctx.getImageData(0, 0, width, width); 92 | } 93 | ctx.strokeStyle = color; 94 | ctx.lineWidth = lineWidth; 95 | ctx.beginPath(); 96 | ctx.arc( 97 | width / 2, 98 | width / 2, 99 | width / 2 - Math.min(1, lineWidth / 2), 100 | 0, 101 | Math.PI * 2, 102 | false, 103 | ); 104 | ctx.stroke(); 105 | return ctx.getImageData(0, 0, width, width); 106 | } 107 | 108 | toSerial() { 109 | return { broker_obj: this.brokerObject(), active: this.active }; 110 | } 111 | 112 | static fromSerial(serial) { 113 | let acc = new Account(serial.broker_obj); 114 | acc.active = serial.active; 115 | return acc; 116 | } 117 | } 118 | 119 | export class AccountManager { 120 | #broker = null; 121 | #registered = []; 122 | #queried = false; 123 | 124 | constructor(broker) { 125 | this.#broker = broker; 126 | } 127 | 128 | hasAccounts() { 129 | return this.#registered.length != 0; 130 | } 131 | 132 | /** 133 | * @returns if we got account data from the broker 134 | */ 135 | hasBrokerData() { 136 | return this.#queried; 137 | } 138 | 139 | getActive() { 140 | return this.#registered.find((a) => a.active); 141 | } 142 | 143 | getRegistered() { 144 | return this.#registered; 145 | } 146 | 147 | logout() { 148 | this.#registered.map((a) => (a.active = false)); 149 | } 150 | 151 | selectAccount(username) { 152 | if (!username) { 153 | let account = this.#registered[0]; 154 | this.logout(); 155 | account.active = true; 156 | return account; 157 | } 158 | const account = this.#registered.find((a) => a.username() == username); 159 | if (account === undefined) { 160 | ssoLog("no account found with username " + username); 161 | return undefined; 162 | } 163 | this.logout(); 164 | account.active = true; 165 | return account; 166 | } 167 | 168 | async loadAccounts() { 169 | if (this.hasBrokerData()) return; 170 | 171 | ssoLog("loading accounts"); 172 | const _accounts = await this.#broker.getAccounts(); 173 | if (!_accounts || !_accounts.length) { 174 | this.#registered = []; 175 | return; 176 | } 177 | // if we already got an account from storage, select the 178 | // corresponding one from the broker as active. 179 | const last_username = this.getActive()?.username(); 180 | this.#registered = _accounts; 181 | if (last_username && this.selectAccount(last_username)) { 182 | ssoLog( 183 | "select previously used account: " + 184 | this.getActive().username(), 185 | ); 186 | } else { 187 | this.selectAccount(); 188 | ssoLog("select first account: " + this.getActive().username()); 189 | } 190 | await Promise.all( 191 | this.#registered.map((a) => this.loadProfilePicture(a)), 192 | ); 193 | } 194 | 195 | async getToken(account) { 196 | if (Date.now() + 60 * 1000 < account.access_token_exp) { 197 | return account.access_token; 198 | } 199 | const graph_token = await this.#broker.acquireTokenSilently(account); 200 | if ("error" in graph_token) { 201 | ssoLog("couldn't acquire API token"); 202 | console.log(graph_token.error); 203 | return; 204 | } 205 | ssoLog("API token acquired for " + account.username()); 206 | account.access_token = graph_token.accessToken; 207 | account.access_token_exp = graph_token.expiresOn; 208 | return account.access_token; 209 | } 210 | 211 | async loadProfilePicture(account) { 212 | const graph_token = await this.getToken(account); 213 | if (!graph_token) return; 214 | const response = await fetch( 215 | "https://graph.microsoft.com/v1.0/me/photos/48x48/$value", 216 | { 217 | headers: { 218 | Accept: "image/jpeg", 219 | Authorization: "Bearer " + graph_token, 220 | }, 221 | }, 222 | ); 223 | if (response.ok) { 224 | let avatar = await createImageBitmap(await response.blob()); 225 | let canvas = new OffscreenCanvas(48, 48); 226 | let ctx = canvas.getContext("2d"); 227 | ctx.save(); 228 | ctx.beginPath(); 229 | ctx.arc(24, 24, 24, 0, Math.PI * 2, false); 230 | ctx.clip(); 231 | ctx.drawImage(avatar, 0, 0); 232 | ctx.restore(); 233 | /* serialize image to data URL (ugly, but portable) */ 234 | let blob = await canvas.convertToBlob(); 235 | const dataUrl = await new Promise((r) => { 236 | let a = new FileReader(); 237 | a.onload = r; 238 | a.readAsDataURL(blob); 239 | }).then((e) => e.target.result); 240 | 241 | /* store image data */ 242 | ctx.clearRect(0, 0, 48, 48); 243 | ctx.drawImage(avatar, 0, 0, 48, 48); 244 | account.setAvatarImgData(ctx.getImageData(0, 0, 48, 48)); 245 | account.avatar = dataUrl; 246 | } else { 247 | ssoLog( 248 | "Warning: Could not get profile picture of " + 249 | account.username(), 250 | ); 251 | } 252 | } 253 | 254 | /* 255 | * Store the current state in the local storage. 256 | * To not leak account data in disabled state, we clear the account object. 257 | */ 258 | async persist() { 259 | if (!this.hasAccounts()) return; 260 | let ssostate = { 261 | state: this.getActive() != null, 262 | accounts: this.getActive() 263 | ? this.#registered.map((a) => a.toSerial()) 264 | : [], 265 | }; 266 | return chrome.storage.local.set({ ssostate }); 267 | } 268 | 269 | async restore() { 270 | let dfd = new Deferred(); 271 | chrome.storage.local.get("ssostate", (data) => { 272 | let active_acc = undefined; 273 | if (!data.ssostate) { 274 | ssoLog("no preserved state found"); 275 | // if the SSO is not explicitly disabled, we assume it is on. 276 | dfd.resolve(true); 277 | return; 278 | } 279 | const state_active = data.ssostate.state; 280 | if (state_active && data.ssostate.accounts) { 281 | this.#registered = data.ssostate.accounts.map((a) => 282 | Account.fromSerial(a), 283 | ); 284 | if (!state_active) this.logout(); 285 | active_acc = this.getActive(); 286 | if (active_acc) { 287 | ssoLog( 288 | "temporarily using last-known account: " + 289 | active_acc.username(), 290 | ); 291 | } 292 | } 293 | dfd.resolve(state_active); 294 | }); 295 | return dfd.promise; 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /popup/menu.js: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-License-Identifier: MPL-2.0 3 | * SPDX-FileCopyrightText: Copyright 2024 Siemens AG 4 | */ 5 | 6 | /* max length of user-provided strings in UI */ 7 | const UI_MAX_STRING_LEN = 30; 8 | 9 | let bg_port = chrome.runtime.connect({ name: "linux-entra-sso" }); 10 | /* communication with backend is in progress */ 11 | let inflight = false; 12 | /* user is logged in */ 13 | let active = false; 14 | /* sso provider url */ 15 | let sso_url = null; 16 | /* current URL filter */ 17 | let current_filter = null; 18 | /* group policy update */ 19 | let gpo = null; 20 | /* size of avatar images */ 21 | const AVATAR_SIZE = 48; 22 | 23 | function set_inflight() { 24 | if (inflight) return false; 25 | inflight = true; 26 | annotate_body_if("pending", true); 27 | return true; 28 | } 29 | 30 | function clear_inflight() { 31 | inflight = false; 32 | annotate_body_if("pending", false); 33 | } 34 | 35 | function annotate_body_if(annotation, state) { 36 | if (state) document.body.classList.add(annotation); 37 | else document.body.classList.remove(annotation); 38 | } 39 | 40 | function annotate_by_id_if(element_id, annotation, state) { 41 | element = document.getElementById(element_id); 42 | if (!element) return; 43 | if (state) element.classList.add(annotation); 44 | else element.classList.remove(annotation); 45 | } 46 | 47 | function setup_color_scheme() { 48 | const scheme = window?.matchMedia?.("(prefers-color-scheme:dark)")?.matches 49 | ? "dark" 50 | : "light"; 51 | document.documentElement.classList.add(scheme); 52 | } 53 | 54 | /** 55 | * If string is short enough, just set the innerText property. 56 | * If not, set it to a cropped version and set the title to the 57 | * full one. 58 | */ 59 | function set_text_cropped(element, str) { 60 | if (str.length <= UI_MAX_STRING_LEN) { 61 | element.innerText = str; 62 | element.title = ""; 63 | } else { 64 | element.innerText = str.slice(0, UI_MAX_STRING_LEN) + "…"; 65 | element.title = str; 66 | } 67 | } 68 | 69 | setup_color_scheme(); 70 | bg_port.onMessage.addListener(async (m) => { 71 | if (m.event == "stateChanged") { 72 | clear_inflight(); 73 | annotate_body_if("has-account", m.accounts.length); 74 | annotate_body_if("nm-connected", m.nm_connected); 75 | 76 | if (m.accounts !== null) { 77 | const accountsdom = document.getElementById("accountlist"); 78 | const entities = m.accounts.map((a) => create_account_entity(a)); 79 | accountsdom.replaceChildren(); 80 | entities.map((e) => accountsdom.appendChild(e)); 81 | } 82 | 83 | active = m.enabled && m.accounts.length; 84 | annotate_by_id_if("entity-guest", "active", !active); 85 | 86 | annotate_by_id_if("broker-state", "connected", m.broker_online); 87 | document.getElementById("broker-state-value").innerText = 88 | m.broker_online ? "connected" : "disconnected"; 89 | document.getElementById("broker-version").innerText = m.broker_version; 90 | 91 | if (m.host_version) { 92 | let pvers = chrome.runtime.getManifest().version; 93 | let vstr = "v" + pvers; 94 | if (m.host_version !== pvers) { 95 | vstr += " (host v" + m.host_version + ")"; 96 | } 97 | document.getElementById("version").innerText = vstr; 98 | } 99 | if (m.device) { 100 | set_text_cropped( 101 | document.getElementById("device-name"), 102 | m.device.name, 103 | ); 104 | document.getElementById("state-compliant-value").innerText = m 105 | .device.compliant 106 | ? "compliant" 107 | : "not compliant"; 108 | annotate_body_if("compliant", m.device.compliant); 109 | } 110 | sso_url = m.sso_url; 111 | gpo = m.gpo_update; 112 | check_sso_provider_perms(); 113 | check_bg_sso_enabled(); 114 | check_gpo_update(); 115 | } 116 | }); 117 | 118 | function create_account_entity(account) { 119 | const entity = document.createElement("div"); 120 | entity.classList.add("entity"); 121 | if (account.active) entity.classList.add("active"); 122 | 123 | const avatarDiv = document.createElement("div"); 124 | avatarDiv.classList.add("avatar"); 125 | 126 | if (account.avatar !== null) { 127 | const canvas = document.createElement("canvas"); 128 | canvas.width = AVATAR_SIZE; 129 | canvas.height = AVATAR_SIZE; 130 | const ctx = canvas.getContext("2d"); 131 | let img = new Image(AVATAR_SIZE, AVATAR_SIZE); 132 | img.src = account.avatar; 133 | img.onload = () => { 134 | ctx.drawImage(img, 0, 0); 135 | }; 136 | avatarDiv.appendChild(canvas); 137 | } else { 138 | const fallbackImg = document.createElement("img"); 139 | fallbackImg.src = "profile-outline.svg"; 140 | fallbackImg.width = AVATAR_SIZE; 141 | fallbackImg.height = AVATAR_SIZE; 142 | fallbackImg.alt = "Avatar"; 143 | avatarDiv.appendChild(fallbackImg); 144 | } 145 | entity.appendChild(avatarDiv); 146 | 147 | const infoDiv = document.createElement("div"); 148 | infoDiv.classList.add("info"); 149 | 150 | const nameDiv = document.createElement("div"); 151 | nameDiv.className = "name"; 152 | nameDiv.innerText = account.name; 153 | infoDiv.appendChild(nameDiv); 154 | 155 | const emailDiv = document.createElement("div"); 156 | emailDiv.className = "email"; 157 | emailDiv.innerText = account.username; 158 | infoDiv.appendChild(emailDiv); 159 | 160 | entity.appendChild(infoDiv); 161 | entity.addEventListener("click", (event) => { 162 | if (account.active) return; 163 | if (!set_inflight(this)) return; 164 | bg_port.postMessage({ command: "enable", username: account.username }); 165 | }); 166 | return entity; 167 | } 168 | 169 | document.getElementById("entity-guest").addEventListener("click", (event) => { 170 | if (!active) return; 171 | if (!set_inflight(this)) return; 172 | bg_port.postMessage({ command: "disable" }); 173 | }); 174 | 175 | function check_sso_provider_perms() { 176 | const permissionsToCheck = { 177 | origins: [sso_url + "/*"], 178 | }; 179 | chrome.permissions.contains(permissionsToCheck).then((result) => { 180 | annotate_by_id_if("message-box", "hidden", result); 181 | }); 182 | } 183 | 184 | async function check_bg_sso_enabled() { 185 | let [tab] = await chrome.tabs.query({ currentWindow: true, active: true }); 186 | if ( 187 | !Object.hasOwn(tab, "url") || 188 | !tab.url.startsWith("https://") || 189 | tab.url.startsWith(sso_url) 190 | ) { 191 | annotate_by_id_if("bg-sso-state", "hidden", true); 192 | return; 193 | } 194 | annotate_by_id_if("bg-sso-state", "hidden", false); 195 | 196 | var tab_hostname = new URL(tab.url).hostname; 197 | current_filter = "https://" + tab_hostname + "/*"; 198 | set_text_cropped(document.getElementById("current-url"), tab_hostname); 199 | const permissionsToCheck = { 200 | origins: [current_filter], 201 | }; 202 | chrome.permissions.contains(permissionsToCheck).then((result) => { 203 | annotate_by_id_if("bg-sso-state", "connected", result); 204 | }); 205 | const state_immutable = 206 | gpo !== null && (gpo.has_catch_all || tab_hostname in gpo.apps_managed); 207 | annotate_by_id_if("bg-sso-state", "immutable", state_immutable); 208 | } 209 | 210 | function check_gpo_update() { 211 | annotate_by_id_if("gpo-update-box", "hidden", gpo === null || !gpo.pending); 212 | } 213 | 214 | function apply_gpo_update() { 215 | if (gpo === null) return; 216 | request_host_permission(gpo.filters_to_add); 217 | remove_host_permission(gpo.filters_to_remove); 218 | } 219 | 220 | function request_host_permission(urls) { 221 | if (urls === null || urls.length == 0) return; 222 | const permissionsToRequest = { 223 | origins: urls, 224 | }; 225 | chrome.permissions.request(permissionsToRequest).then((granted) => { 226 | if (granted) { 227 | console.log("Permission granted"); 228 | // No need to update the UI as this will trigger the permission 229 | // changed event in the background script, which triggers an 230 | // UI update. 231 | } else { 232 | console.log("Failed to get permission"); 233 | } 234 | }); 235 | // The permission-request window might open below the webextensions panel. 236 | // This has been observed on Thunderbird 128. Close the panel, so the user 237 | // can grant the permission. 238 | window.close(); 239 | } 240 | 241 | function remove_host_permission(urls) { 242 | if (urls === null || urls.length == 0) return; 243 | const permissionsToRemove = { 244 | origins: urls, 245 | }; 246 | chrome.permissions.remove(permissionsToRemove).then((removed) => { 247 | if (removed) console.log("Permission removed"); 248 | else console.log("Failed to remove permission"); 249 | }); 250 | } 251 | 252 | // Requires user interaction, as otherwise we lack the permission to 253 | // request further host permissions 254 | document.getElementById("grant-access").addEventListener("click", (event) => { 255 | request_host_permission([current_filter]); 256 | }); 257 | 258 | document 259 | .getElementById("withdraw-access") 260 | .addEventListener("click", (event) => { 261 | remove_host_permission([current_filter]); 262 | }); 263 | 264 | document 265 | .getElementById("grant-access-sso") 266 | .addEventListener("click", (event) => { 267 | request_host_permission([sso_url + "/*"]); 268 | }); 269 | 270 | document 271 | .getElementById("apply-gpo-update") 272 | .addEventListener("click", (event) => { 273 | apply_gpo_update(); 274 | }); 275 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # Entra ID SSO via Microsoft Identity Broker on Linux 3 | # 4 | # SPDX-License-Identifier: MPL-2.0 5 | # SPDX-FileCopyrightText: Copyright (c) Jan Kiszka, 2020-2024 6 | # SPDX-FileCopyrightText: Copyright (c) Siemens AG, 2024 7 | # 8 | # Authors: 9 | # Jan Kiszka 10 | # Felix Moessbauer 11 | # 12 | # This Source Code Form is subject to the terms of the Mozilla Public 13 | # License, v. 2.0. If a copy of the MPL was not distributed with this 14 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 15 | # 16 | 17 | prefix ?= /usr/local 18 | exec_prefix ?= $(prefix) 19 | libexecdir ?= $(exec_prefix)/libexec 20 | # do not prefix with $(prefix) as these dirs are defined by the browsers 21 | firefox_nm_dir ?= /usr/lib/mozilla/native-messaging-hosts 22 | chrome_nm_dir ?= /etc/opt/chrome/native-messaging-hosts 23 | chrome_ext_dir ?= /usr/share/google-chrome/extensions 24 | chromium_nm_dir ?= /etc/chromium/native-messaging-hosts 25 | # python3 system interpreter for global installs 26 | python3_bin ?= $(shell which python3) 27 | 28 | ifeq ($(V),1) 29 | Q = 30 | else 31 | Q = @ 32 | endif 33 | 34 | PACKAGE_NAME=Linux-Entra-SSO 35 | 36 | RELEASE_TAG ?= $(shell git describe --match "v[0-9].[0-9]*" --dirty) 37 | WEBEXT_VERSION=$(shell echo $(RELEASE_TAG) | sed -e s:^v::) 38 | ARCHIVE_NAME=$(PACKAGE_NAME)-$(RELEASE_TAG) 39 | 40 | COMMON_INPUT_FILES= \ 41 | LICENSES/MPL-2.0.txt \ 42 | src/account.js \ 43 | src/background.js \ 44 | src/broker.js \ 45 | src/device.js \ 46 | src/platform.js \ 47 | src/policy.js \ 48 | src/utils.js \ 49 | icons/profile-outline_48.png \ 50 | icons/profile-outline_48.png.license \ 51 | popup/menu.css \ 52 | popup/menu.js \ 53 | popup/menu.html 54 | 55 | CHROME_INPUT_FILES= \ 56 | $(COMMON_INPUT_FILES) \ 57 | platform/chrome/manifest.json \ 58 | platform/chrome/manifest.json.license \ 59 | platform/chrome/js/platform-chrome.js \ 60 | platform/chrome/js/platform-factory.js \ 61 | platform/chrome/storage-schema.json \ 62 | platform/chrome/storage-schema.json.license \ 63 | icons/linux-entra-sso_48.png \ 64 | icons/linux-entra-sso_48.png.license \ 65 | icons/linux-entra-sso_128.png \ 66 | icons/linux-entra-sso_128.png.license 67 | 68 | FIREFOX_INPUT_FILES= \ 69 | $(COMMON_INPUT_FILES) \ 70 | platform/firefox/manifest.json \ 71 | platform/firefox/manifest.json.license \ 72 | platform/firefox/js/platform-firefox.js \ 73 | platform/firefox/js/platform-factory.js \ 74 | icons/linux-entra-sso.svg \ 75 | icons/profile-outline.svg 76 | 77 | THUNDERBIRD_INPUT_FILES= \ 78 | $(COMMON_INPUT_FILES) \ 79 | platform/thunderbird/manifest.json \ 80 | platform/thunderbird/manifest.json.license \ 81 | platform/thunderbird/js/platform-thunderbird.js \ 82 | platform/thunderbird/js/platform-factory.js \ 83 | icons/linux-entra-sso.svg \ 84 | icons/profile-outline.svg 85 | 86 | # common files for all platforms (relative to build directory) 87 | CHROME_PACKAGE_FILES= \ 88 | $(COMMON_INPUT_FILES) \ 89 | src/platform-chrome.js \ 90 | src/platform-factory.js \ 91 | manifest.json \ 92 | manifest.json.license \ 93 | storage-schema.json \ 94 | storage-schema.json.license \ 95 | icons/linux-entra-sso_48.png \ 96 | icons/linux-entra-sso_48.png.license \ 97 | icons/linux-entra-sso_128.png \ 98 | icons/linux-entra-sso_128.png.license \ 99 | popup/profile-outline.svg 100 | 101 | FIREFOX_PACKAGE_FILES= \ 102 | $(COMMON_INPUT_FILES) \ 103 | src/platform-firefox.js \ 104 | src/platform-factory.js \ 105 | manifest.json \ 106 | manifest.json.license \ 107 | icons/linux-entra-sso.svg \ 108 | popup/profile-outline.svg 109 | 110 | THUNDERBIRD_PACKAGE_FILES= \ 111 | $(FIREFOX_PACKAGE_FILES) \ 112 | src/platform-thunderbird.js 113 | 114 | UPDATE_VERSION='s|"version":.*|"version": "$(VERSION)",|' 115 | UPDATE_VERSION_PY='s|0.0.0-dev|$(WEBEXT_VERSION)|g' 116 | UPDATE_PYTHON_INTERPRETER='1,1s:^\#!.*:\#!$(python3_bin):' 117 | 118 | CHROME_EXT_ID=$(shell $(CURDIR)/platform/chrome/get-ext-id.py $(CURDIR)/build/chrome/) 119 | CHROME_EXT_ID_SIGNED=jlnfnnolkbjieggibinobhkjdfbpcohn 120 | 121 | # debian package related vars 122 | DEBIAN_PV = $(shell echo $(RELEASE_TAG) | sed -e s:^v::) 123 | DEBIAN_PN = linux-entra-sso 124 | DEBIAN_DESCRIPTION = Entra ID SSO via Microsoft Identity Broker on Linux 125 | DEBIAN_DESTDIR := $(CURDIR)/debuild.d 126 | DEBIAN_ARCH = all 127 | DEBIAN_PKG_DIR = $(CURDIR)/pkgs 128 | DEBIAN_PKG_FILE = $(DEBIAN_PKG_DIR)/$(DEBIAN_PN)_$(DEBIAN_PV)_$(DEBIAN_ARCH).deb 129 | 130 | all package: clean $(CHROME_INPUT_FILES) $(FIREFOX_INPUT_FILES) $(THUNDERBIRD_INPUT_FILES) 131 | for P in firefox thunderbird chrome; do \ 132 | mkdir -p build/$$P/icons build/$$P/popup; \ 133 | cp platform/$$P/manifest* build/$$P; \ 134 | cp -rf LICENSES src build/$$P/; \ 135 | cp platform/$$P/js/* build/$$P/src; \ 136 | done 137 | cp -r build/firefox/icons build/firefox/popup build/thunderbird/ 138 | cp platform/chrome/storage* build/chrome/ 139 | cp icons/*.svg icons/profile-outline_48.* build/firefox/icons/ 140 | cp icons/*.png* icons/profile-outline.svg build/chrome/icons/ 141 | cp popup/menu.* icons/linux-entra-sso.svg icons/profile-outline.svg build/firefox/popup/ 142 | cp popup/menu.* icons/linux-entra-sso.svg icons/profile-outline.svg build/chrome/popup/ 143 | # thunderbird is almost identical to Firefox 144 | cp -r build/firefox/icons build/firefox/popup build/thunderbird/ 145 | cp build/firefox/src/platform-firefox.js build/thunderbird/src/ 146 | cd build/firefox && zip -r ../$(ARCHIVE_NAME).firefox.xpi $(FIREFOX_PACKAGE_FILES) && cd ../../; 147 | cd build/thunderbird && zip -r ../$(ARCHIVE_NAME).thunderbird.xpi $(THUNDERBIRD_PACKAGE_FILES) && cd ../../; 148 | cd build/chrome && zip -r ../$(ARCHIVE_NAME).chrome.zip $(CHROME_PACKAGE_FILES) && cd ../../; 149 | 150 | deb: 151 | $(MAKE) install DESTDIR=$(DEBIAN_DESTDIR) python3_bin=/usr/bin/python3 prefix=/usr 152 | install --mode 644 -D --target-directory=$(DEBIAN_DESTDIR)/usr/share/doc/$(DEBIAN_PN) README.md CONTRIBUTING.md MAINTAINERS.md PRIVACY.md LICENSES/MPL-2.0.txt 153 | install --mode 755 --directory $(DEBIAN_DESTDIR)/DEBIAN 154 | { \ 155 | echo Package: $(DEBIAN_PN); \ 156 | echo Architecture: $(DEBIAN_ARCH); \ 157 | echo Section: admin; \ 158 | echo Priority: optional; \ 159 | echo 'Maintainer: Dr. Johann Pfefferl '; \ 160 | echo Installed-Size: `du --summarize $(DEBIAN_DESTDIR) | cut --fields=1`; \ 161 | echo 'Depends: python3-pydbus, python3-gi'; \ 162 | echo Version: $(DEBIAN_PV); \ 163 | echo Description: $(DEBIAN_DESCRIPTION); \ 164 | } > $(DEBIAN_DESTDIR)/DEBIAN/control 165 | install --mode 775 --directory $(DEBIAN_PKG_DIR) 166 | dpkg-deb --deb-format=2.0 --root-owner-group --build $(DEBIAN_DESTDIR) $(DEBIAN_PKG_DIR) 167 | @echo Package can be found here: $(DEBIAN_PKG_FILE) 168 | 169 | deb_clean: 170 | rm -rf $(DEBIAN_PKG_DIR) $(DEBIAN_DESTDIR) 171 | 172 | clean: deb_clean 173 | rm -rf build 174 | 175 | release: 176 | ${Q}if [ -z "$(VERSION)" ]; then \ 177 | echo "VERSION is not set"; \ 178 | exit 1; \ 179 | fi 180 | ${Q}if [ -n "`git status -s -uno`" ]; then \ 181 | echo "Working directory is dirty!"; \ 182 | exit 1; \ 183 | fi 184 | ${Q}sed -i $(UPDATE_VERSION) platform/*/manifest.json 185 | git commit -s platform/firefox/manifest.json platform/thunderbird/manifest.json platform/chrome/manifest.json -m "Bump version number" 186 | git tag -as v$(VERSION) -m "Release v$(VERSION)" 187 | 188 | local-install-firefox: 189 | install -d ~/.mozilla/native-messaging-hosts 190 | install -m 0644 platform/firefox/linux_entra_sso.json ~/.mozilla/native-messaging-hosts 191 | ${Q}sed -i 's|/usr/local/lib/linux-entra-sso/|'$(HOME)'/.mozilla/|' ~/.mozilla/native-messaging-hosts/linux_entra_sso.json 192 | install -m 0755 linux-entra-sso.py ~/.mozilla 193 | ${Q}sed -i $(UPDATE_VERSION_PY) ~/.mozilla/linux-entra-sso.py 194 | 195 | local-install-chrome: 196 | install -d ~/.config/google-chrome/NativeMessagingHosts 197 | install -d ~/.config/chromium/NativeMessagingHosts 198 | install -m 0644 platform/chrome/linux_entra_sso.json ~/.config/google-chrome/NativeMessagingHosts 199 | install -m 0644 platform/chrome/linux_entra_sso.json ~/.config/chromium/NativeMessagingHosts 200 | ${Q}sed -i 's|/usr/local/lib/linux-entra-sso/|'$(HOME)'/.config/google-chrome/|' ~/.config/google-chrome/NativeMessagingHosts/linux_entra_sso.json 201 | ${Q}sed -i 's|/usr/local/lib/linux-entra-sso/|'$(HOME)'/.config/google-chrome/|' ~/.config/chromium/NativeMessagingHosts/linux_entra_sso.json 202 | # compute extension id and and grant permission 203 | ${Q}sed -i 's|{extension_id}|$(CHROME_EXT_ID)|' ~/.config/google-chrome/NativeMessagingHosts/linux_entra_sso.json 204 | ${Q}sed -i 's|{extension_id}|$(CHROME_EXT_ID)|' ~/.config/chromium/NativeMessagingHosts/linux_entra_sso.json 205 | install -m 0755 linux-entra-sso.py ~/.config/google-chrome 206 | ${Q}sed -i $(UPDATE_VERSION_PY) ~/.config/google-chrome/linux-entra-sso.py 207 | 208 | local-install-brave: 209 | install -d ~/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts 210 | install -m 0644 platform/chrome/linux_entra_sso.json ~/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts 211 | ${Q}sed -i 's|/usr/local/lib/linux-entra-sso/|'$(HOME)'/.config/BraveSoftware/Brave-Browser/|' ~/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts/linux_entra_sso.json 212 | # compute extension id and and grant permission 213 | ${Q}sed -i 's|{extension_id}|$(CHROME_EXT_ID)|' ~/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts/linux_entra_sso.json 214 | install -m 0755 linux-entra-sso.py ~/.config/BraveSoftware/Brave-Browser 215 | ${Q}sed -i $(UPDATE_VERSION_PY) ~/.config/BraveSoftware/Brave-Browser/linux-entra-sso.py 216 | 217 | local-install: local-install-firefox local-install-chrome local-install-brave 218 | 219 | # For testing, we provide a mock implementation of the broker communication 220 | local-install-mock: local-install 221 | install -m 0755 tests/linux_entra_sso_mock.py ~/.mozilla 222 | ${Q}sed -i 's|linux-entra-sso.py|linux_entra_sso_mock.py|' ~/.mozilla/native-messaging-hosts/linux_entra_sso.json 223 | install -m 0755 tests/linux_entra_sso_mock.py ~/.config/google-chrome 224 | ${Q}sed -i 's|linux-entra-sso.py|linux_entra_sso_mock.py|' ~/.config/google-chrome/NativeMessagingHosts/linux_entra_sso.json 225 | ${Q}sed -i 's|linux-entra-sso.py|linux_entra_sso_mock.py|' ~/.config/chromium/NativeMessagingHosts/linux_entra_sso.json 226 | install -m 0755 tests/linux_entra_sso_mock.py ~/.config/BraveSoftware/Brave-Browser 227 | ${Q}sed -i 's|linux-entra-sso.py|linux_entra_sso_mock.py|' ~/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts/linux_entra_sso.json 228 | 229 | install: 230 | ${Q}[ -z "$(python3_bin)" ] && { echo "python3 not found. Please set 'python3_bin'."; exit 1; } || true 231 | # Host application 232 | install -d $(DESTDIR)/$(libexecdir)/linux-entra-sso 233 | install -m 0755 linux-entra-sso.py $(DESTDIR)/$(libexecdir)/linux-entra-sso 234 | ${Q}sed -i $(UPDATE_VERSION_PY) $(DESTDIR)/$(libexecdir)/linux-entra-sso/linux-entra-sso.py 235 | ${Q}sed -i $(UPDATE_PYTHON_INTERPRETER) $(DESTDIR)/$(libexecdir)/linux-entra-sso/linux-entra-sso.py 236 | # Firefox 237 | install -d $(DESTDIR)/$(firefox_nm_dir) 238 | install -m 0644 platform/firefox/linux_entra_sso.json $(DESTDIR)/$(firefox_nm_dir) 239 | ${Q}sed -i 's|/usr/local/lib/|'$(libexecdir)/'|' $(DESTDIR)/$(firefox_nm_dir)/linux_entra_sso.json 240 | # Chrome 241 | install -d $(DESTDIR)/$(chrome_nm_dir) 242 | install -m 0644 platform/chrome/linux_entra_sso.json $(DESTDIR)/$(chrome_nm_dir) 243 | ${Q}sed -i 's|/usr/local/lib/|'$(libexecdir)/'|' $(DESTDIR)/$(chrome_nm_dir)/linux_entra_sso.json 244 | ${Q}sed -i '/{extension_id}/d' $(DESTDIR)/$(chrome_nm_dir)/linux_entra_sso.json 245 | install -d $(DESTDIR)/$(chrome_ext_dir) 246 | install -m 0644 platform/chrome/extension.json $(DESTDIR)/$(chrome_ext_dir)/$(CHROME_EXT_ID_SIGNED).json 247 | # Chromium 248 | install -d $(DESTDIR)/$(chromium_nm_dir) 249 | install -m 0644 platform/chrome/linux_entra_sso.json $(DESTDIR)/$(chromium_nm_dir) 250 | ${Q}sed -i 's|/usr/local/lib/|'$(libexecdir)/'|' $(DESTDIR)/$(chromium_nm_dir)/linux_entra_sso.json 251 | ${Q}sed -i '/{extension_id}/d' $(DESTDIR)/$(chromium_nm_dir)/linux_entra_sso.json 252 | 253 | uninstall: 254 | rm -rf $(DESTDIR)/$(libexecdir)/linux-entra-sso 255 | rm -f $(DESTDIR)/$(firefox_nm_dir)/linux_entra_sso.json 256 | rm -f $(DESTDIR)/$(chrome_nm_dir)/linux_entra_sso.json 257 | rm -f $(DESTDIR)/$(chromium_nm_dir)/linux_entra_sso.json 258 | rm -f $(DESTDIR)/$(chrome_ext_dir)/$(CHROME_EXT_ID_SIGNED).json 259 | 260 | local-uninstall-firefox: 261 | rm -f ~/.mozilla/native-messaging-hosts/linux_entra_sso.json ~/.mozilla/linux-entra-sso.py 262 | 263 | local-uninstall-chrome: 264 | rm -f ~/.config/google-chrome/NativeMessagingHosts/linux_entra_sso.json ~/.config/google-chrome/linux-entra-sso.py 265 | rm -f ~/.config/chromium/NativeMessagingHosts/linux_entra_sso.json 266 | 267 | local-uninstall-brave: 268 | rm -f ~/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts/linux_entra_sso.json ~/.config/BraveSoftware/Brave-Browser/linux-entra-sso.py 269 | rm -f ~/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts/linux-entra-sso.py 270 | 271 | local-uninstall: local-uninstall-firefox local-uninstall-chrome local-uninstall-brave 272 | 273 | .PHONY: clean release deb deb_clean 274 | .PHONY: local-install-firefox local-install-chrome local-install-brave local-install local-install-mock install 275 | .PHONY: local-uninstall-firefox local-uninstall-chrome local-uninstall-brave local-uninstall uninstall 276 | -------------------------------------------------------------------------------- /linux-entra-sso.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # SPDX-License-Identifier: MPL-2.0 3 | # SPDX-FileCopyrightText: Copyright 2024 Siemens AG 4 | 5 | # pylint: disable=missing-docstring,invalid-name 6 | 7 | # Renable invalid-name check, it should only cover the module name 8 | # pylint: enable=invalid-name 9 | 10 | import argparse 11 | import sys 12 | import json 13 | import struct 14 | import uuid 15 | import ctypes 16 | import time 17 | from signal import SIGINT 18 | from threading import Thread, RLock 19 | from gi.repository import GLib, Gio 20 | from pydbus import SessionBus 21 | 22 | # version is replaced on installation 23 | LINUX_ENTRA_SSO_VERSION = "0.0.0-dev" 24 | 25 | # the ssoUrl is a mandatory parameter when requesting a PRT SSO 26 | # Cookie, but the correct value is not checked as of 30.05.2024 27 | # by the authorization backend. By that, a static (fallback) 28 | # value can be used, if no real value is provided. 29 | SSO_URL_DEFAULT = "https://login.microsoftonline.com/" 30 | EDGE_BROWSER_CLIENT_ID = "d7b530a4-7680-4c23-a8bf-c52c121d2e87" 31 | BROKER_START_TIMEOUT = 5 32 | # dbus start service reply codes 33 | START_REPLY_SUCCESS = 1 34 | START_REPLY_ALREADY_RUNNING = 2 35 | # prctl constants 36 | PR_SET_PDEATHSIG = 1 37 | 38 | 39 | class NativeMessaging: 40 | @staticmethod 41 | def get_message(): 42 | """ 43 | Read a message from stdin and decode it. 44 | """ 45 | raw_length = sys.stdin.buffer.read(4) 46 | if not raw_length: 47 | sys.exit(0) 48 | message_length = struct.unpack("@I", raw_length)[0] 49 | message = sys.stdin.buffer.read(message_length).decode("utf-8") 50 | return json.loads(message) 51 | 52 | @staticmethod 53 | def encode_message(message_content): 54 | """ 55 | Encode a message for transmission, given its content 56 | """ 57 | encoded_content = json.dumps(message_content, separators=(",", ":")).encode( 58 | "utf-8" 59 | ) 60 | encoded_length = struct.pack("@I", len(encoded_content)) 61 | return {"length": encoded_length, "content": encoded_content} 62 | 63 | @staticmethod 64 | def send_message(encoded_message): 65 | """ 66 | Send an encoded message to stdout 67 | """ 68 | sys.stdout.buffer.write(encoded_message["length"]) 69 | sys.stdout.buffer.write(encoded_message["content"]) 70 | sys.stdout.buffer.flush() 71 | 72 | 73 | class SsoMib: 74 | BROKER_NAME = "com.microsoft.identity.broker1" 75 | BROKER_PATH = "/com/microsoft/identity/broker1" 76 | GRAPH_SCOPES = ["https://graph.microsoft.com/.default"] 77 | 78 | def __init__(self, daemon=False): 79 | self._bus = SessionBus() 80 | self.broker = None 81 | self.session_id = uuid.uuid4() 82 | self._state_changed_cb = None 83 | self._last_state_reported = False 84 | if daemon: 85 | self._introspect_broker(fail_on_error=False) 86 | self._monitor_bus() 87 | 88 | def _introspect_broker(self, fail_on_error=True): 89 | timeout = time.time() + BROKER_START_TIMEOUT 90 | while not self.broker and time.time() < timeout: 91 | try: 92 | self.broker = self._bus.get(self.BROKER_NAME, self.BROKER_PATH) 93 | self._report_state_change() 94 | return 95 | except GLib.Error as err: 96 | # GDBus.Error:org.freedesktop.dbus.errors.UnknownObject: 97 | # Introspecting on non-existant object 98 | # See https://github.com/siemens/linux-entra-sso/issues/33 99 | if err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.DBUS_ERROR): 100 | time.sleep(0.1) 101 | continue 102 | if fail_on_error: 103 | raise RuntimeError("Could not start broker") 104 | 105 | def _monitor_bus(self): 106 | self._bus.subscribe( 107 | sender="org.freedesktop.DBus", 108 | object="/org/freedesktop/DBus", 109 | signal="NameOwnerChanged", 110 | arg0=self.BROKER_NAME, 111 | signal_fired=self._broker_state_changed, 112 | ) 113 | 114 | def _broker_state_changed( 115 | self, sender, object, iface, signal, params 116 | ): # pylint: disable=redefined-builtin,too-many-arguments 117 | _ = (sender, object, iface, signal) 118 | # params = (name, old_owner, new_owner) 119 | new_owner = params[2] 120 | if new_owner: 121 | self._introspect_broker() 122 | else: 123 | # we need to ensure that the next dbus call will 124 | # wait until the broker is fully initialized again 125 | self.broker = None 126 | self._report_state_change() 127 | 128 | def _report_state_change(self): 129 | current_state = bool(self.broker) 130 | if self._state_changed_cb and self._last_state_reported != current_state: 131 | self._state_changed_cb(current_state) 132 | self._last_state_reported = current_state 133 | 134 | def on_broker_state_changed(self, callback): 135 | """ 136 | Register a callback to be called when the broker state changes. 137 | The callback should accept a single boolean argument, indicating 138 | if the broker is online or not. 139 | """ 140 | self._state_changed_cb = callback 141 | 142 | @staticmethod 143 | def _get_auth_parameters(account, scopes, sso_url=None): 144 | params = { 145 | "account": account, 146 | "additionalQueryParametersForAuthorization": {}, 147 | "authority": "https://login.microsoftonline.com/common", 148 | "authorizationType": 8, # OAUTH2 149 | "clientId": EDGE_BROWSER_CLIENT_ID, 150 | "redirectUri": "https://login.microsoftonline.com" 151 | "/common/oauth2/nativeclient", 152 | "requestedScopes": scopes, 153 | "username": account["username"], 154 | "uxContextHandle": -1, 155 | } 156 | if sso_url: 157 | params["ssoUrl"] = sso_url 158 | return params 159 | 160 | def get_accounts(self): 161 | self._introspect_broker() 162 | context = { 163 | "clientId": EDGE_BROWSER_CLIENT_ID, 164 | "redirectUri": str(self.session_id), 165 | } 166 | resp = self.broker.getAccounts("0.0", str(self.session_id), json.dumps(context)) 167 | return json.loads(resp) 168 | 169 | def acquire_prt_sso_cookie( 170 | self, account, sso_url, scopes=GRAPH_SCOPES 171 | ): # pylint: disable=dangerous-default-value 172 | self._introspect_broker() 173 | request = { 174 | "account": account, 175 | "authParameters": SsoMib._get_auth_parameters(account, scopes, sso_url), 176 | "mamEnrollment": False, 177 | "ssoUrl": sso_url, 178 | } 179 | token = json.loads( 180 | self.broker.acquirePrtSsoCookie( 181 | "0.0", str(self.session_id), json.dumps(request) 182 | ) 183 | ) 184 | return token 185 | 186 | def acquire_token_silently( 187 | self, account, scopes=GRAPH_SCOPES 188 | ): # pylint: disable=dangerous-default-value 189 | self._introspect_broker() 190 | request = { 191 | "account": account, 192 | "authParameters": SsoMib._get_auth_parameters(account, scopes), 193 | } 194 | token = json.loads( 195 | self.broker.acquireTokenSilently( 196 | "0.0", str(self.session_id), json.dumps(request) 197 | ) 198 | ) 199 | return token 200 | 201 | def get_broker_version(self): 202 | self._introspect_broker() 203 | params = json.dumps({"msalCppVersion": LINUX_ENTRA_SSO_VERSION}) 204 | resp = json.loads( 205 | self.broker.getLinuxBrokerVersion("0.0", str(self.session_id), params) 206 | ) 207 | resp["native"] = LINUX_ENTRA_SSO_VERSION 208 | return resp 209 | 210 | 211 | def run_as_native_messaging(): 212 | iomutex = RLock() 213 | 214 | def respond(command, message): 215 | NativeMessaging.send_message( 216 | NativeMessaging.encode_message({"command": command, "message": message}) 217 | ) 218 | 219 | def notify_state_change(online): 220 | with iomutex: 221 | respond("brokerStateChanged", "online" if online else "offline") 222 | 223 | def handle_command(cmd, received_message): 224 | if cmd == "acquirePrtSsoCookie": 225 | account = received_message["account"] 226 | sso_url = received_message["ssoUrl"] or SSO_URL_DEFAULT 227 | token = ssomib.acquire_prt_sso_cookie(account, sso_url) 228 | respond(cmd, token) 229 | elif cmd == "acquireTokenSilently": 230 | account = received_message["account"] 231 | scopes = received_message.get("scopes") or ssomib.GRAPH_SCOPES 232 | token = ssomib.acquire_token_silently(account, scopes) 233 | respond(cmd, token) 234 | elif cmd == "getAccounts": 235 | respond(cmd, ssomib.get_accounts()) 236 | elif cmd == "getVersion": 237 | respond(cmd, ssomib.get_broker_version()) 238 | 239 | def run_dbus_monitor(): 240 | # inform other side about initial state 241 | notify_state_change(bool(ssomib.broker)) 242 | loop = GLib.MainLoop() 243 | loop.run() 244 | 245 | def register_terminate_with_parent(): 246 | libc = ctypes.CDLL("libc.so.6") 247 | libc.prctl(PR_SET_PDEATHSIG, SIGINT, 0, 0, 0) 248 | 249 | print("Running as native messaging instance.", file=sys.stderr) 250 | print("For interactive mode, start with --interactive", file=sys.stderr) 251 | 252 | # on chrome and chromium, the parent process does not reliably 253 | # terminate the process when the parent process is killed. 254 | register_terminate_with_parent() 255 | 256 | ssomib = SsoMib(daemon=True) 257 | ssomib.on_broker_state_changed(notify_state_change) 258 | monitor = Thread(target=run_dbus_monitor) 259 | monitor.start() 260 | while True: 261 | received_message = NativeMessaging.get_message() 262 | with iomutex: 263 | cmd = received_message["command"] 264 | try: 265 | handle_command(cmd, received_message) 266 | except Exception as exp: # pylint: disable=broad-except 267 | err = {"error": f"Failure during request processing: {str(exp)}"} 268 | respond(cmd, err) 269 | 270 | 271 | def run_interactive(): 272 | def _get_account(accounts, idx): 273 | try: 274 | return accounts["accounts"][idx] 275 | except IndexError: 276 | json.dump( 277 | {"error": f"invalid account index {idx}"}, 278 | indent=2, 279 | fp=sys.stdout, 280 | ) 281 | print() 282 | sys.exit(1) 283 | 284 | parser = argparse.ArgumentParser() 285 | parser.add_argument( 286 | "-i", 287 | "--interactive", 288 | action="store_true", 289 | help="run in interactive mode", 290 | ) 291 | parser.add_argument( 292 | "-a", 293 | "--account", 294 | type=int, 295 | default=0, 296 | help="account index to use for operations", 297 | ) 298 | parser.add_argument( 299 | "-s", 300 | "--ssoUrl", 301 | default=SSO_URL_DEFAULT, 302 | help="ssoUrl part of SSO PRT cookie request", 303 | ) 304 | parser.add_argument( 305 | "command", 306 | choices=[ 307 | "getAccounts", 308 | "getVersion", 309 | "acquirePrtSsoCookie", 310 | "acquireTokenSilently", 311 | "monitor", 312 | ], 313 | ) 314 | args = parser.parse_args() 315 | 316 | monitor_mode = args.command == "monitor" 317 | ssomib = SsoMib(daemon=monitor_mode) 318 | if monitor_mode: 319 | print("Monitoring D-Bus for broker availability.") 320 | ssomib.on_broker_state_changed( 321 | lambda online: print( 322 | f"{ssomib.BROKER_NAME} is now " f"{'online' if online else 'offline'}." 323 | ) 324 | ) 325 | GLib.MainLoop().run() 326 | return 327 | 328 | accounts = ssomib.get_accounts() 329 | if len(accounts["accounts"]) == 0: 330 | print("warning: no accounts registered.", file=sys.stderr) 331 | 332 | if args.command == "getAccounts": 333 | json.dump(accounts, indent=2, fp=sys.stdout) 334 | elif args.command == "getVersion": 335 | json.dump(ssomib.get_broker_version(), indent=2, fp=sys.stdout) 336 | elif args.command == "acquirePrtSsoCookie": 337 | account = _get_account(accounts, args.account) 338 | cookie = ssomib.acquire_prt_sso_cookie(account, args.ssoUrl) 339 | json.dump(cookie, indent=2, fp=sys.stdout) 340 | elif args.command == "acquireTokenSilently": 341 | account = _get_account(accounts, args.account) 342 | token = ssomib.acquire_token_silently(account) 343 | json.dump(token, indent=2, fp=sys.stdout) 344 | # add newline 345 | print() 346 | 347 | 348 | if __name__ == "__main__": 349 | if "--interactive" in sys.argv or "-i" in sys.argv: 350 | run_interactive() 351 | else: 352 | run_as_native_messaging() 353 | -------------------------------------------------------------------------------- /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 https://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 | -------------------------------------------------------------------------------- /LICENSES/MPL-2.0.txt: -------------------------------------------------------------------------------- 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 https://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 | --------------------------------------------------------------------------------