Bind invites to a provider, and email them

Wanted shape: the owner signs in with GitHub, everyone invited later signs in
with Google. That was previously a convention, not a boundary -- may_login
checked only that a live row existed, so any enabled provider could satisfy
any invite.

Now an invite is bound to the provider it was issued for. friend@gmail.com
invited for Google cannot be redeemed over GitHub, and there is never a
second, weaker door onto an existing account. The per-request re-check still
passes provider=None, because there the question is only whether the account
is still live.

Two related fixes fell out:

  - upsert_user no longer refreshes `provider` from the login. It is part of
    the identity an admin issued and may_login now enforces it, so overwriting
    it on sign-in would let a successful login silently rewrite the very rule
    that permitted it.
  - a Google invite must be a full email address. A bare username would
    authorise something Google can never return, and the invitee would see
    only "not authorised" with no way to work out why.

INVITE EMAILS. Adding a user now mails them, when SMTP is configured and there
is an address -- for Google there always is, since the identity IS one. The
response reports whether the mail actually went and why not, because "Invited"
alone reads as success while the person waits for an email nobody sent.

The link carries NO token, deliberately. The row already names the exact
identity permitted and Google will not hand us an unverified address, so
possession of the mail is not what authorises anyone: a forwarded invite
grants nothing, nothing expires, and resend is always safe. A signed expiring
token would only buy the ability to withdraw an unused invitation, which
deleting the row already does.

Resend is offered in the panel on anyone who has an address and has never
signed in -- the likeliest reason being that they missed it.

tests/test_invites.py: 29 assertions driving a real SMTP conversation against
a sink server, plus the full provider-binding matrix. 119 tests pass across
four suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Opus 5 committed 2026-08-05 12:05:44 -05:00
1 parent 205e0a44b8
commit da8bf67093
5 files changed
+434 -30

No files matched your search

+108 -12
View File
@@ -251,17 +251,26 @@ def new_session(username: str) -> str:
return token
def may_login(username: str, row: sqlite3.Row | None) -> bool:
def may_login(username: str, row: sqlite3.Row | None,
provider: str | None = None) -> bool:
"""The single authorization rule, used at login AND on every request.
Bootstrap allowlist first, then a live, non-disabled user row. Deliberately
one function: an authorization check that runs only at login is not a
revocation mechanism, it is a delay — disabling someone would leave their
session valid for up to MUSE_SESSION_DAYS.
An invite is bound to the provider it was issued for. Invite
friend@gmail.com for Google and Google is the only way in for that
identity; there is no second, weaker door onto the same account. `provider`
is None on the per-request re-check, where the question is only whether the
account is still live.
"""
if username.lower() in ALLOWED_USERS:
return True
return bool(row) and not row["disabled"]
if not row or row["disabled"]:
return False
return provider is None or row["provider"] == provider
def current_user(request: Request) -> sqlite3.Row | None:
@@ -447,7 +456,11 @@ def upsert_user(username: str, avatar: str = "", display: str = "",
"INSERT INTO users (username,provider,role,display,avatar,last_seen) "
"VALUES (?,?,?,?,?,datetime('now')) "
"ON CONFLICT(username) DO UPDATE SET "
" last_seen=datetime('now'), provider=excluded.provider, "
" last_seen=datetime('now'), "
# provider is NOT refreshed from the login. It is part of the
# identity an admin invited, and may_login now enforces it — so
# overwriting it here would let a successful login silently
# rewrite the very rule that permitted it.
" avatar=COALESCE(NULLIF(excluded.avatar,''), users.avatar), "
" display=COALESCE(NULLIF(excluded.display,''), users.display)",
(username, provider, "admin" if first else "user", display, avatar))
@@ -736,6 +749,28 @@ def recipient(username: str | None) -> str | None:
return username if r["provider"] == "google" and "@" in username else None
async def notify_invited(row: sqlite3.Row, by: str, to: str):
"""Tell someone they've been invited.
There is no token in this link and none is needed. The row already names
the exact identity that may sign in, and Google will not hand us an
unverified address — so possession of the email is not what authorises
anyone, and a leaked link grants nothing. It is a courtesy, not a
credential, which is also why it can be resent freely.
"""
label = PROVIDERS.get(row["provider"], {}).get("label", row["provider"])
where = BASE_URL or "your muse instance"
await send_mail(
to, f"{by} invited you to muse",
f"You've been given access to muse — request a song, and it turns up\n"
f"in the music library, correctly tagged.\n\n"
f" {where}\n\n"
f"Sign in with {label}, using the account for {row['username']}.\n"
f"That address is what authorises you, so it has to be that one.\n\n"
f"Playback lives in Navidrome, not here — muse only decides what gets\n"
f"added to the library.\n")
async def notify_imported(job, dest):
to = recipient(job["requested_by"])
if not to:
@@ -1176,10 +1211,15 @@ async def auth_callback(request: Request, code: str = "", state: str = ""):
with db() as c:
existing = c.execute("SELECT * FROM users WHERE username=?",
(username,)).fetchone()
if not may_login(username, existing):
if not may_login(username, existing, provider):
# Log the attempt: on an internet-facing box this is the signal that
# someone found the login page.
why = "disabled" if existing else "not invited"
if not existing:
why = "not invited"
elif existing["disabled"]:
why = "disabled"
else:
why = f"invited for {existing['provider']}, tried {provider}"
log.warning("auth: DENIED %s user %r (%s)", provider, username, why)
return JSONResponse({"error": "not authorised"}, status_code=403)
@@ -1963,6 +2003,10 @@ class NewUser(BaseModel):
role: str = "user"
quota: int = 0
storage_quota_gb: float = 0
# Where to send the invitation. Omit for a Google invite — the username
# already is the address. Only needed for GitHub, where a login is not one.
email: str | None = None
notify: bool = True
class PatchUser(BaseModel):
@@ -1970,6 +2014,9 @@ class PatchUser(BaseModel):
quota: int | None = None
storage_quota_gb: float | None = None
disabled: bool | None = None
# So a typo in an invited address is fixable without deleting the account
# and losing its request history.
email: str | None = None
def user_dict(r: sqlite3.Row) -> dict:
@@ -1997,8 +2044,12 @@ async def admin_users(request: Request):
@app.post("/api/admin/users")
async def admin_add_user(body: NewUser, request: Request):
"""Invite someone. There is no email or token — the row IS the invitation:
once it exists, that identity can sign in with the matching provider."""
"""Invite someone.
The row IS the authorisation: once it exists, that identity can sign in
with the provider it was invited for, and only that one. The email is a
notification on top, sent when there is an address and SMTP is configured.
"""
who = require_admin(request)
name = body.username.strip()
if not name:
@@ -2007,17 +2058,57 @@ async def admin_add_user(body: NewUser, request: Request):
raise HTTPException(400, f"role must be one of {', '.join(ROLES)}")
if body.provider not in PROVIDERS:
raise HTTPException(400, f"provider must be one of {', '.join(PROVIDERS)}")
if body.provider not in ENABLED_PROVIDERS:
raise HTTPException(
400, f"{body.provider} is not enabled on this instance "
f"(MUSE_AUTH={AUTH_MODE})")
# A Google identity is its own address, so require it to look like one --
# otherwise the invite silently authorises a username Google can never
# return, and the person just gets "not authorised" with no explanation.
if body.provider == "google" and not EMAIL_RE.match(name):
raise HTTPException(400, "a Google invite must be the full email address")
email = (body.email or "").strip() or (name if body.provider == "google" else "")
if email and not EMAIL_RE.match(email):
raise HTTPException(400, "that does not look like an email address")
with db() as c:
if c.execute("SELECT 1 FROM users WHERE username=?", (name,)).fetchone():
raise HTTPException(409, "that user already exists")
c.execute(
"INSERT INTO users (username,provider,role,quota,storage_quota,invited_by) "
"VALUES (?,?,?,?,?,?)",
"INSERT INTO users (username,provider,role,quota,storage_quota,"
"invited_by,email,notify) VALUES (?,?,?,?,?,?,?,?)",
(name, body.provider, body.role, max(0, body.quota),
int(max(0.0, body.storage_quota_gb) * 2**30), who))
int(max(0.0, body.storage_quota_gb) * 2**30), who,
email or None, 1 if body.notify else 0))
r = c.execute("SELECT * FROM users WHERE username=?", (name,)).fetchone()
log.info("admin: %s invited %s as %s", who, name, body.role)
return user_dict(r)
log.info("admin: %s invited %s as %s (%s)", who, name, body.role, body.provider)
sent = bool(MAIL_ON and email)
if sent:
await notify_invited(r, who, email)
return {**user_dict(r), "invite_sent": sent,
"invite_why": None if sent else
("no SMTP configured on this instance" if not MAIL_ON
else "no email address for this account")}
@app.post("/api/admin/users/{username}/resend")
async def admin_resend_invite(username: str, request: Request):
"""Send the invitation again. Safe to call repeatedly — the link carries
no token, so resending neither extends nor grants anything."""
who = require_admin(request)
with db() as c:
r = c.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
if not r:
raise HTTPException(404, "no such user")
if not MAIL_ON:
raise HTTPException(409, "no SMTP configured on this instance")
if not r["email"]:
raise HTTPException(409, "no email address on file for that account")
await notify_invited(r, who, r["email"])
return {"ok": True, "sent_to": r["email"]}
@app.patch("/api/admin/users/{username}")
@@ -2054,6 +2145,11 @@ async def admin_patch_user(username: str, body: PatchUser, request: Request):
vals.append(int(max(0.0, body.storage_quota_gb) * 2**30))
if body.disabled is not None:
sets.append("disabled=?"); vals.append(1 if body.disabled else 0)
if body.email is not None:
e = body.email.strip()
if e and not EMAIL_RE.match(e):
raise HTTPException(400, "that does not look like an email address")
sets.append("email=?"); vals.append(e or None)
if not sets:
raise HTTPException(400, "nothing to change")