Add M365 Login plugin: Microsoft Entra ID sign-in for existing users
Adds a WordPress plugin that places a customisable "Sign in with Microsoft" button on wp-login.php and signs existing users in via the OpenID Connect authorization code flow with PKCE. Users are matched by e-mail address only; no accounts are created. Security: single-use state/nonce bound to an HttpOnly cookie, ID token signature verification against Microsoft's JWKS (RS256 only) with issuer/audience/tenant/expiry/nonce checks, optional tenant pinning, account binding to the Microsoft object ID, e-mail domain allow-list, client secret encrypted at rest (AES-256-GCM). Admin: settings screen with connection, button and security tabs, live button preview, colour presets, media-library icon picker, redirect URI copy button and tenant connectivity test. Packaging for WordPress.org: readme.txt with External services section, GPL-2.0 license, uninstall.php, POT + German translations, .distignore, build script, PHPCS config and CI running Plugin Check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2
This commit is contained in:
commit
3e3e87b399
35 changed files with 5413 additions and 0 deletions
27
bin/build-zip.sh
Executable file
27
bin/build-zip.sh
Executable file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env bash
|
||||
# Builds build/m365-login.zip – the folder inside the archive is named after the
|
||||
# WordPress.org slug (m365-login), regardless of the repository name.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SLUG="m365-login"
|
||||
BUILD="$ROOT/build"
|
||||
STAGE="$BUILD/$SLUG"
|
||||
|
||||
rm -rf "$BUILD"
|
||||
mkdir -p "$STAGE"
|
||||
|
||||
# rsync honours .distignore-style excludes.
|
||||
rsync -a --delete \
|
||||
--exclude-from="$ROOT/.distignore" \
|
||||
--exclude 'build' \
|
||||
"$ROOT/" "$STAGE/"
|
||||
|
||||
(
|
||||
cd "$BUILD"
|
||||
rm -f "$SLUG.zip"
|
||||
zip -rq "$SLUG.zip" "$SLUG"
|
||||
)
|
||||
|
||||
echo "Created $BUILD/$SLUG.zip"
|
||||
unzip -l "$BUILD/$SLUG.zip" | tail -n +4 | head -n -2 | awk '{print $4}'
|
||||
103
bin/compile-mo.py
Normal file
103
bin/compile-mo.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Compiles every languages/*.po into a binary .mo (no gettext tools required).
|
||||
|
||||
Usage: python3 bin/compile-mo.py
|
||||
"""
|
||||
import ast
|
||||
import glob
|
||||
import os
|
||||
import struct
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def parse_po(path):
|
||||
messages = {}
|
||||
ctx = msgid = msgstr = None
|
||||
plural = None
|
||||
plurals = {}
|
||||
section = None
|
||||
|
||||
def flush():
|
||||
if msgid is None:
|
||||
return
|
||||
key = msgid if ctx is None else ctx + "\x04" + msgid
|
||||
if plural is not None:
|
||||
key = key + "\x00" + plural
|
||||
value = "\x00".join(plurals[i] for i in sorted(plurals))
|
||||
else:
|
||||
value = msgstr or ""
|
||||
if msgid == "" or value:
|
||||
messages[key] = value
|
||||
|
||||
for raw in open(path, encoding="utf-8"):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("msgctxt "):
|
||||
flush()
|
||||
ctx, msgid, msgstr, plural, plurals = ast.literal_eval(line[8:]), None, None, None, {}
|
||||
section = "ctx"
|
||||
elif line.startswith("msgid_plural "):
|
||||
plural = ast.literal_eval(line[13:])
|
||||
section = "plural"
|
||||
elif line.startswith("msgid "):
|
||||
if section != "ctx":
|
||||
flush()
|
||||
ctx, msgstr, plural, plurals = None, None, None, {}
|
||||
msgid = ast.literal_eval(line[6:])
|
||||
section = "id"
|
||||
elif line.startswith("msgstr["):
|
||||
idx = int(line[7:line.index("]")])
|
||||
plurals[idx] = ast.literal_eval(line[line.index("]") + 1:].strip())
|
||||
section = ("pl", idx)
|
||||
elif line.startswith("msgstr "):
|
||||
msgstr = ast.literal_eval(line[7:])
|
||||
section = "str"
|
||||
elif line.startswith('"'):
|
||||
chunk = ast.literal_eval(line)
|
||||
if section == "ctx":
|
||||
ctx += chunk
|
||||
elif section == "id":
|
||||
msgid += chunk
|
||||
elif section == "plural":
|
||||
plural += chunk
|
||||
elif section == "str":
|
||||
msgstr += chunk
|
||||
elif isinstance(section, tuple):
|
||||
plurals[section[1]] += chunk
|
||||
flush()
|
||||
return messages
|
||||
|
||||
|
||||
def write_mo(messages, path):
|
||||
keys = sorted(messages)
|
||||
ids = b""
|
||||
strs = b""
|
||||
offsets = []
|
||||
for k in keys:
|
||||
kb = k.encode("utf-8")
|
||||
vb = messages[k].encode("utf-8")
|
||||
offsets.append((len(ids), len(kb), len(strs), len(vb)))
|
||||
ids += kb + b"\x00"
|
||||
strs += vb + b"\x00"
|
||||
n = len(keys)
|
||||
keystart = 7 * 4 + 16 * n
|
||||
valuestart = keystart + len(ids)
|
||||
koffsets = []
|
||||
voffsets = []
|
||||
for o1, l1, o2, l2 in offsets:
|
||||
koffsets += [l1, o1 + keystart]
|
||||
voffsets += [l2, o2 + valuestart]
|
||||
output = struct.pack("Iiiiiii", 0x950412DE, 0, n, 7 * 4, 7 * 4 + n * 8, 0, 0)
|
||||
output += struct.pack("%di" % len(koffsets), *koffsets)
|
||||
output += struct.pack("%di" % len(voffsets), *voffsets)
|
||||
output += ids + strs
|
||||
open(path, "wb").write(output)
|
||||
|
||||
|
||||
for po in sorted(glob.glob(os.path.join(ROOT, "languages", "*.po"))):
|
||||
mo = po[:-3] + ".mo"
|
||||
messages = parse_po(po)
|
||||
write_mo(messages, mo)
|
||||
print("Compiled %s (%d entries)" % (os.path.relpath(mo, ROOT), len(messages) - 1))
|
||||
94
bin/make-pot.py
Normal file
94
bin/make-pot.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Minimal POT generator for this plugin (fallback when WP-CLI is unavailable).
|
||||
|
||||
Usage: python3 bin/make-pot.py
|
||||
Prefer `wp i18n make-pot . languages/m365-login.pot` when WP-CLI is installed.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DOMAIN = "m365-login"
|
||||
FUNCS = r"(?:__|_e|esc_html__|esc_html_e|esc_attr__|esc_attr_e|_x|_ex|esc_html_x|esc_attr_x|_n|_nx)"
|
||||
STR = r"(?:'((?:[^'\\]|\\.)*)'|\"((?:[^\"\\]|\\.)*)\")"
|
||||
PATTERN = re.compile(FUNCS + r"\s*\(\s*" + STR + r"(?:\s*,\s*" + STR + r")?(?:\s*,\s*" + STR + r")?\s*[,)]", re.S)
|
||||
COMMENT = re.compile(r"/\*\s*translators:\s*(.*?)\*/", re.S)
|
||||
|
||||
|
||||
def unescape(s):
|
||||
return s.replace("\\'", "'").replace('\\"', '"').replace("\\\\", "\\")
|
||||
|
||||
|
||||
def po_escape(s):
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
||||
|
||||
|
||||
entries = OrderedDict()
|
||||
for dirpath, dirnames, filenames in os.walk(ROOT):
|
||||
dirnames[:] = [d for d in dirnames if d not in (".git", "vendor", "node_modules", "build", "bin", "docs", ".github", ".wordpress-org")]
|
||||
for fn in sorted(filenames):
|
||||
if not fn.endswith(".php"):
|
||||
continue
|
||||
path = os.path.join(dirpath, fn)
|
||||
rel = os.path.relpath(path, ROOT)
|
||||
src = open(path, encoding="utf-8").read()
|
||||
for m in PATTERN.finditer(src):
|
||||
func = m.group(0).split("(")[0].strip()
|
||||
msgid = unescape(m.group(1) if m.group(1) is not None else m.group(2))
|
||||
second = m.group(3) if m.group(3) is not None else m.group(4)
|
||||
third = m.group(5) if m.group(5) is not None else m.group(6)
|
||||
context = None
|
||||
plural = None
|
||||
if func in ("_x", "_ex", "esc_html_x", "esc_attr_x"):
|
||||
context = unescape(second) if second is not None else None
|
||||
elif func in ("_n",):
|
||||
plural = unescape(second) if second is not None else None
|
||||
elif func == "_nx":
|
||||
plural = unescape(second) if second is not None else None
|
||||
context = unescape(third) if third is not None else None
|
||||
line = src.count("\n", 0, m.start()) + 1
|
||||
before = src[max(0, m.start() - 400):m.start()]
|
||||
cm = COMMENT.findall(before)
|
||||
comment = " ".join(cm[-1].split()) if cm else None
|
||||
key = (context, msgid, plural)
|
||||
e = entries.setdefault(key, {"refs": [], "comment": None})
|
||||
e["refs"].append("%s:%d" % (rel, line))
|
||||
if comment:
|
||||
e["comment"] = comment
|
||||
|
||||
header = '''# Copyright (C) 2026 friloo
|
||||
# This file is distributed under the GPL-2.0-or-later.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: M365 Login 1.0.0\\n"
|
||||
"Report-Msgid-Bugs-To: https://github.com/friloo/wp-m365-login/issues\\n"
|
||||
"MIME-Version: 1.0\\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\\n"
|
||||
"Content-Transfer-Encoding: 8bit\\n"
|
||||
"POT-Creation-Date: 2026-09-22T00:00:00+00:00\\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\\n"
|
||||
"X-Generator: bin/make-pot.py\\n"
|
||||
"X-Domain: m365-login\\n"
|
||||
|
||||
'''
|
||||
out = [header]
|
||||
for (context, msgid, plural), e in entries.items():
|
||||
if e["comment"]:
|
||||
out.append("#. translators: %s\n" % e["comment"])
|
||||
out.append("#: %s\n" % " ".join(e["refs"]))
|
||||
if context:
|
||||
out.append('msgctxt "%s"\n' % po_escape(context))
|
||||
out.append('msgid "%s"\n' % po_escape(msgid))
|
||||
if plural:
|
||||
out.append('msgid_plural "%s"\n' % po_escape(plural))
|
||||
out.append('msgstr[0] ""\nmsgstr[1] ""\n\n')
|
||||
else:
|
||||
out.append('msgstr ""\n\n')
|
||||
|
||||
dest = os.path.join(ROOT, "languages", DOMAIN + ".pot")
|
||||
open(dest, "w", encoding="utf-8").write("".join(out))
|
||||
print("Wrote %s (%d strings)" % (os.path.relpath(dest, ROOT), len(entries)))
|
||||
Loading…
Add table
Add a link
Reference in a new issue