';
- function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
- function msg(text, ok) {
- var el = document.getElementById('wp-pw-msg');
- el.style.display = 'block'; el.textContent = text;
- el.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)'; el.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)';
- }
- ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
- document.body.appendChild(ov);
- document.getElementById('wp-pw-cancel').onclick = close;
- document.getElementById('wp-pw-cur').focus();
- document.getElementById('wp-pw-save').onclick = function () {
- var cur = document.getElementById('wp-pw-cur').value;
- var n1 = document.getElementById('wp-pw-new').value;
- var n2 = document.getElementById('wp-pw-new2').value;
- if (!cur || !n1) { msg('Please fill in every field.', false); return; }
- if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; }
- if (n1 !== n2) { msg('New passwords do not match.', false); return; }
- fetch('/api/auth/password', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ current_password: cur, new_password: n1 })
- })
- .then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
- .then(function (res) {
- if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); }
- else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); }
- })
- .catch(function () { msg('Could not reach the server.', false); });
- };
- };
+ // window.wpChangePassword used to open a change-password dialog here. Removed in
+ // T10.4 (D15/D16): there is no local password to change anymore — identity is
+ // Okta's job. The "Password" item that called this is gone from wp-sidenav.js
+ // too.
// ── permissions helpers ────────────────────────────────────────────────────
// The server enforces all of this; these are for hiding controls the signed-in
@@ -171,7 +119,8 @@
// Admin, Users and Sign out from the navigation drawer, and being one unbreakable
// 412px run with an inline white-space:nowrap, it was what clipped the bar at 390px
// and cut "Sign out" in half — F2. wp-sidenav.js now carries all of it, including
- // the two items that were only here: Language & time, and Password.
+ // the item that was only here: Language & time. (Password was the other one; T10.4
+ // removed it along with the rest of local auth — D15/D16.)
//
// Nothing replaces it. Every signed-in page mounts the drawer, so there is no page
// left that would need a floating fallback pill.
diff --git a/html/users.html b/html/users.html
index 1e9fa2b..1a4f423 100644
--- a/html/users.html
+++ b/html/users.html
@@ -26,9 +26,6 @@
room for "Assistant Project Manager" without pushing Actions off screen. */
#users-table table td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; }
#users-banner:not(:empty), #scope-banner:not(:empty){ margin-bottom:var(--s3); }
- /* The create form is a lot of fields; give the password one room to breathe and
- let the project picker take a full row of its own. */
- #nu-password{ flex:1 1 200px; }
#nu-projects{ margin-top:var(--s2); }
#nu-projects .pickrow{ padding:var(--s1) var(--s1); }
/* A manager with one project doesn't need a scrolling picker; a manager with
@@ -83,12 +80,12 @@
'+
@@ -254,19 +252,6 @@ function projAccessCell(u){
// ── row actions ───────────────────────────────────────────────────────────────
// Each one reloads on failure so a control can never sit there showing a value the
// server refused.
-async function resetPw(id, username){
- // The min-12 rule was stated in the prompt label and enforced only by the
- // server round-trip; the kit's validate() answers AT the input instead.
- const pw = await wpPromptDialog({title:'Reset password',
- message:'Set a new password for "'+username+'". Their existing sessions are signed out.',
- label:'New password (min 12 characters)',
- validate:v => (v && v.length >= 12) ? '' : 'At least 12 characters.'});
- if(pw === null) return;
- const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
- if(status === 200) toast('Password reset for '+username+'. Their existing sessions are signed out.');
- else wpAlertDialog({title:'Reset failed', message:'Could not reset the password: '+apiError(status, json)});
-}
-
async function toggleActive(id, makeActive){
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
if(status === 200) loadUsers();
@@ -344,25 +329,27 @@ function renderCreateForm(){
async function createUser(){
const msg = document.getElementById('users-create-msg');
const val = id => (document.getElementById(id)||{}).value || '';
+ // The username entered here MUST match what Okta's identity claim will send for
+ // this person exactly — this creates the account ahead of their first sign-in,
+ // and that's how a later Okta sign-in finds this row instead of provisioning a
+ // second one. See create_user()'s docstring in server/app.py.
const username = val('nu-username').trim();
- const password = val('nu-password');
const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')]
.map(c => c.value);
const say = (color, text) => { msg.style.color = color; msg.textContent = text; };
if(!username){ say('var(--red)','Username is required.'); return; }
- if(password.length < 12){ say('var(--red)','Password must be at least 12 characters.'); return; }
if(_scope.scope !== 'all' && !project_ids.length){
say('var(--red)','Pick at least one project — you administer users per project.'); return;
}
say('var(--muted)','Creating…');
const { status, json } = await api('POST','/api/auth/users',{
- username, password, project_ids,
+ username, project_ids,
full_name: val('nu-fullname').trim(), email: val('nu-email').trim(),
role: val('nu-role'), project_role: val('nu-project-role'),
});
if(status === 200){
say('var(--green)','✓ Created '+username+'.');
- ['nu-username','nu-fullname','nu-email','nu-password'].forEach(id => document.getElementById(id).value = '');
+ ['nu-username','nu-fullname','nu-email'].forEach(id => document.getElementById(id).value = '');
loadUsers();
} else {
say('var(--red)','✕ '+apiError(status, json, 'Could not create the account'));
diff --git a/html/wp-sidenav.js b/html/wp-sidenav.js
index 3ca490e..aa38868 100644
--- a/html/wp-sidenav.js
+++ b/html/wp-sidenav.js
@@ -53,7 +53,6 @@
{ section: 'Account' },
{ action: 'wpPreferences', icon: '◷', label: 'Language & time',
sub: 'Dates, numbers and time zone' },
- { action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' },
];
function esc(v) {
diff --git a/server/alembic/versions/1d60a608bb51_drop_local_password.py b/server/alembic/versions/1d60a608bb51_drop_local_password.py
new file mode 100644
index 0000000..5a5d0de
--- /dev/null
+++ b/server/alembic/versions/1d60a608bb51_drop_local_password.py
@@ -0,0 +1,29 @@
+"""drop local password (T10.4, wave 10 / D15 / D16)
+
+Real deletion, not a toggle: no local password exists anymore, only Okta OIDC.
+`password_hash` was NOT NULL at the database level since the baseline schema, so
+downgrade re-adds it with server_default='' rather than leaving existing rows
+without a value — the same pattern used for the locale/timezone drop-precedent
+columns, applied in reverse.
+
+Revision ID: 1d60a608bb51
+Revises: a1b8c6d4e2f9
+Create Date: 2026-09-03 00:00:00.000000
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '1d60a608bb51'
+down_revision = 'a1b8c6d4e2f9'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.drop_column('users', 'password_hash')
+
+
+def downgrade() -> None:
+ op.add_column('users', sa.Column('password_hash', sa.String(length=200), nullable=False, server_default=''))
diff --git a/server/app.py b/server/app.py
index 58c1334..c1c4e6b 100644
--- a/server/app.py
+++ b/server/app.py
@@ -13,8 +13,6 @@ import logging
import os
import re
import uuid
-from datetime import timedelta, timezone
-from time import monotonic
from typing import Any, Optional
from urllib.parse import urlparse
@@ -609,14 +607,8 @@ def health():
# ── Authentication ─────────────────────────────────────────────────────────────
-class LoginIn(BaseModel):
- username: str
- password: str
-
-
class NewUserIn(BaseModel):
username: str
- password: str
full_name: str = ""
email: str = ""
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
@@ -638,24 +630,6 @@ class PreferencesIn(BaseModel):
timezone: Optional[str] = None
-class ForgotPasswordIn(BaseModel):
- username: str = "" # username or email
-
-
-class ResetPasswordIn(BaseModel):
- token: str
- new_password: str
-
-
-class PasswordChangeIn(BaseModel):
- current_password: str
- new_password: str
-
-
-class AdminPasswordIn(BaseModel):
- new_password: str
-
-
class ActiveIn(BaseModel):
is_active: bool
@@ -678,47 +652,6 @@ class AutoAddIn(BaseModel):
role: str = ""
-LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
-LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
-
-
-@app.post("/api/auth/login")
-def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
- """Verify credentials and, on success, set the HttpOnly session cookie.
- Throttles online password guessing: after LOGIN_MAX_ATTEMPTS consecutive
- failures an account is locked for LOGIN_LOCKOUT_MINUTES."""
- user = auth.find_user(db, body.username)
- now = models.utcnow()
- # Always run the hash comparison first — even for missing or locked accounts —
- # so response timing doesn't leak which usernames exist. verify_password
- # tolerates an empty hash.
- valid = auth.verify_password(body.password, user.password_hash if user else "")
- locked = user.locked_until if user else None
- if locked is not None and locked.tzinfo is None:
- locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes; normalize to UTC
- if locked is not None and locked > now:
- raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
- if not user or not valid:
- if user:
- user.failed_attempts = (user.failed_attempts or 0) + 1
- if user.failed_attempts >= LOGIN_MAX_ATTEMPTS:
- user.locked_until = now + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
- user.failed_attempts = 0
- log_event(db, user.username, "login_locked", "user", user.id, summary=user.username,
- detail={"minutes": LOGIN_LOCKOUT_MINUTES})
- db.commit()
- raise HTTPException(status_code=401, detail="Invalid username or password")
- if not user.is_active:
- raise HTTPException(status_code=403, detail="Account is disabled")
- user.failed_attempts = 0
- user.locked_until = None
- user.last_login_at = now
- db.commit()
- token = auth.create_token(user)
- auth.set_session_cookie(response, request, token)
- return {"user": user.to_dict()}
-
-
@app.post("/api/auth/logout")
def logout(response: Response):
auth.clear_session_cookie(response)
@@ -762,14 +695,13 @@ async def okta_callback(request: Request, db: Session = Depends(get_db)):
# at all — this app still decides what a first-time sign-in may do. A new
# account gets the lowest-privilege role and no project membership; an admin
# or project super user grants access afterward, same as any account created
- # by hand today (create_user() above). No password is ever set — Okta is the
- # only credential (D15's "no stored password").
+ # by hand today (create_user() above). No password field exists at all —
+ # Okta is the only credential (D15/D16, real deletion as of T10.4).
user = models.User(
id=gen_id("user"),
username=identity,
email=(claims.get("email") or "").strip(),
full_name=(claims.get("name") or "").strip(),
- password_hash="",
role=auth.ROLE_PROJECT_USER,
)
db.add(user)
@@ -794,113 +726,6 @@ async def okta_callback(request: Request, db: Session = Depends(get_db)):
return redirect
-# ── Self-service password reset (needs email switched on) ──────────────────────
-RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120"))
-# In-process throttle: one reset mail per (account, client) per cooldown. Enough to
-# stop someone using the form to spam a colleague's inbox. Per-worker and lost on
-# restart — deliberately simple; the token expiry is the real control.
-_reset_last: dict[str, float] = {}
-
-
-def _reset_throttled(request: Request, username: str) -> bool:
- now = monotonic()
- key = f"{(username or '').strip().lower()}|{request.client.host if request.client else ''}"
- prev = _reset_last.get(key)
- if prev is not None and (now - prev) < RESET_COOLDOWN_SECONDS:
- return True
- _reset_last[key] = now
- if len(_reset_last) > 5000: # bound the dict on a long-lived worker
- cutoff = now - RESET_COOLDOWN_SECONDS
- for k in [k for k, t in _reset_last.items() if t < cutoff]:
- _reset_last.pop(k, None)
- return False
-
-
-def reset_body(user: "models.User", link: str, minutes: int) -> str:
- # No account detail beyond the username, and no customer data — same rule as
- # the assignment mail. The link is the only sensitive thing in here.
- who = user.full_name or user.username
- return (
- f"Hi {who},\n\n"
- f"A password reset was requested for your Work Package Suite account "
- f"({user.username}).\n\n"
- f"Set a new password:\n{link}\n\n"
- f"The link expires in {minutes} minutes and can only be used once. "
- f"If you didn't request this, you can ignore this email — your current "
- f"password still works.\n"
- )
-
-
-@app.get("/api/auth/reset-available")
-def reset_available(db: Session = Depends(get_db)):
- """Whether the login page should offer 'Forgot password'. Self-service reset
- depends entirely on outbound email, so it's off unless email is enabled AND
- SMTP is configured — otherwise the only route is an admin reset."""
- s = notify.get_settings(db)
- return {"enabled": bool(s.get("email_enabled")) and notify.smtp_ready(s)}
-
-
-@app.post("/api/auth/forgot-password")
-def forgot_password(body: ForgotPasswordIn, request: Request, db: Session = Depends(get_db)):
- """Email a reset link. Always returns the same 200 response whether or not the
- account exists — this endpoint is unauthenticated, so it must not become a
- username/email oracle. Failures are recorded in the audit log instead."""
- s = notify.get_settings(db)
- if not (s.get("email_enabled") and notify.smtp_ready(s)):
- raise HTTPException(
- status_code=503,
- detail="Password reset by email isn't available. Ask an administrator to reset it for you.",
- )
- if _reset_throttled(request, body.username):
- # Same shape as the success response — no oracle, no mail bomb.
- return {"ok": True, "message": "If that account exists, a reset link is on its way."}
- user = auth.find_user(db, body.username)
- if user and user.is_active and user.email:
- base = (s.get("app_base_url") or "").rstrip("/")
- token = auth.create_reset_token(user)
- link = f"{base}/login.html?reset={token}" if base else f"/login.html?reset={token}"
- sent = notify.send_now(
- db, user.email,
- "Work Package Suite — reset your password",
- reset_body(user, link, auth.RESET_MINUTES),
- )
- log_event(db, user.username, "password_reset_requested", "user", user.id,
- summary=user.username, detail={"emailed": bool(sent)})
- db.commit()
- else:
- # Log the miss for the admin's benefit; the caller can't tell the difference.
- log_event(db, "(anonymous)", "password_reset_miss", "user", "",
- summary=(body.username or "")[:200],
- detail={"reason": "no account, inactive, or no email on file"})
- db.commit()
- return {"ok": True, "message": "If that account exists, a reset link is on its way."}
-
-
-@app.post("/api/auth/reset-password")
-def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
- """Complete a reset using the emailed token. The token carries the user's
- token_version, and finishing a reset bumps it — so the link is single-use and
- every existing session for that account is signed out."""
- claims = auth.decode_reset_token(body.token or "")
- if not claims:
- raise HTTPException(status_code=400, detail="This reset link is invalid or has expired. Request a new one.")
- user = db.get(models.User, claims.get("sub"))
- if not user or not user.is_active:
- raise HTTPException(status_code=400, detail="This reset link is no longer valid.")
- if (claims.get("ver", 0) or 0) != (user.token_version or 0):
- raise HTTPException(status_code=400, detail="This reset link has already been used. Request a new one.")
- problem = auth.password_problem(body.new_password, user.username, user.email)
- if problem:
- raise HTTPException(status_code=400, detail=problem)
- user.password_hash = auth.hash_password(body.new_password)
- user.token_version = (user.token_version or 0) + 1 # burns the link + all sessions
- # A completed reset also clears any login lockout — the person has proven
- # control of the mailbox, so there's nothing left to throttle.
- user.failed_attempts = 0
- user.locked_until = None
- log_event(db, user.username, "password_reset", "user", user.id, summary=user.username)
- db.commit()
- return {"ok": True}
@app.get("/api/auth/me")
@@ -956,22 +781,6 @@ def set_preferences(body: PreferencesIn, user: models.User = Depends(auth.get_cu
return {"user": user.to_dict()}
-@app.post("/api/auth/password")
-def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
- if not auth.verify_password(body.current_password, user.password_hash):
- raise HTTPException(status_code=400, detail="Current password is incorrect")
- problem = auth.password_problem(body.new_password, user.username, user.email)
- if problem:
- raise HTTPException(status_code=400, detail=problem)
- user.password_hash = auth.hash_password(body.new_password)
- user.token_version = (user.token_version or 0) + 1 # invalidate all OTHER existing sessions
- db.commit()
- db.refresh(user)
- # Keep this session logged in by re-issuing a cookie carrying the new version.
- auth.set_session_cookie(response, request, auth.create_token(user))
- return {"ok": True}
-
-
# ── User administration ─────────────────────────────────────────────────────────
# Two kinds of caller reach these routes: an app admin, who manages every account,
# and a Project Super User, who manages the accounts on the projects they administer.
@@ -1059,9 +868,14 @@ def user_scope(user: models.User = Depends(auth.get_current_user), db: Session =
@app.post("/api/auth/users")
def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
- problem = auth.password_problem(body.password, body.username, body.email)
- if problem:
- raise HTTPException(status_code=400, detail=problem)
+ """Create an account ahead of its first Okta sign-in — e.g. to put it on
+ projects or hand it a role before anyone has ever signed in as them.
+
+ `body.username` MUST match what Okta's identity claim will send for this
+ person exactly (see OKTA_IDENTITY_CLAIM, server/okta_auth.py) — auth.find_user()
+ is how a later Okta sign-in locates this row (T10.3). A mismatch doesn't
+ fail loudly; it silently produces a second, JIT-provisioned account instead
+ of signing this person into the one just created here."""
allowed = grantable_roles(actor)
if body.role not in allowed:
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
@@ -1093,7 +907,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
username=body.username.strip(),
email=body.email.strip(),
full_name=body.full_name.strip(),
- password_hash=auth.hash_password(body.password),
role=body.role,
project_role=body.project_role.strip()[:120],
)
@@ -1120,24 +933,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
return directory_entry(db, u, actor)
-@app.post("/api/auth/users/{user_id}/password")
-def admin_reset_password(user_id: str, body: AdminPasswordIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
- u = load_target_user(db, user_id)
- require_see_user(db, actor, u)
- require_manage_user(db, actor, u)
- problem = auth.password_problem(body.new_password, u.username, u.email)
- if problem:
- raise HTTPException(status_code=400, detail=problem)
- u.password_hash = auth.hash_password(body.new_password)
- u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
- # An administrative password reset was the one user-account change that left no
- # trace; it is the most impersonation-adjacent thing on this page, so it logs.
- log_event(db, actor, "password_reset", "user", u.id, summary=u.username,
- detail={"by": "administrator"})
- db.commit()
- return {"ok": True}
-
-
@app.post("/api/auth/users/{user_id}/active")
def set_user_active(user_id: str, body: ActiveIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
u = load_target_user(db, user_id)
diff --git a/server/auth.py b/server/auth.py
index dac4e8d..17b9bd2 100644
--- a/server/auth.py
+++ b/server/auth.py
@@ -1,10 +1,11 @@
"""Authentication for the Work Package Suite.
-A self-contained username/password login. Passwords are stored only as bcrypt
-hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie
-(`wp_session`). Because the token is signed and self-validating, there is no
-server-side session store — every request is checked by verifying the cookie's
-signature and expiry (see `auth_gate` and `get_current_user`).
+Identity is confirmed by Okta (OIDC authorization-code flow, see server/okta_auth.py
+and the routes in server/app.py); there is no local password anywhere in this app
+(D15, D16 — T10.4 removed the last of it). A successful sign-in issues a signed JWT
+that rides in an HttpOnly cookie (`wp_session`). Because the token is signed and
+self-validating, there is no server-side session store — every request is checked by
+verifying the cookie's signature and expiry (see `auth_gate` and `get_current_user`).
Security model:
• The real boundary is `auth_gate` (middleware in app.py): every /api/ data
@@ -17,7 +18,9 @@ Security model:
warning and invalidates every session on restart) so dev still works.
Permissions roles (`User.role`) — distinct from a person's job function on the
-project, which lives in `User.project_role` and grants nothing:
+project, which lives in `User.project_role` and grants nothing. Decided entirely
+locally: Okta gates WHO can authenticate at all, this app decides what an
+authenticated account may do — see D15/D16.
• admin application administrator: user administration, app settings,
and implicit access to every project.
• project_super_user
@@ -30,15 +33,13 @@ project, which lives in `User.project_role` and grants nothing:
modify a SOP after it has been completed, and delete projects.
• project_user normal member: creates and edits work packages, authors a SOP
up to completion. May NOT delete WPs or change a completed SOP.
+ Also where Okta JIT provisioning (T10.3) lands a brand-new
+ account — the lowest-privilege role, promoted locally from
+ there by an admin (see manage_users.py for the bootstrap case).
The user-administration SCOPE of a super user is worked out in server/app.py
(`managed_project_ids`, `manage_user_problem`), because it depends on project
membership rows — this module only decides which roles carry the power at all.
-
-Password reset: a short-lived signed token (see `create_reset_token`) is emailed
-to the account's address. It is single-use by construction — it embeds the user's
-`token_version`, which is bumped when the password changes, so a used or
-superseded link stops validating.
"""
import os
import secrets
@@ -46,7 +47,6 @@ import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
-import bcrypt
import jwt
from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import select, func
@@ -59,10 +59,8 @@ log = logging.getLogger("wpsuite.auth")
COOKIE_NAME = "wp_session"
JWT_ALG = "HS256"
-# How long a login lasts before the user must sign in again.
+# How long a session lasts before the person must sign in again.
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
-# How long an emailed password-reset link stays valid.
-RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
# ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin"
@@ -121,30 +119,7 @@ def is_project_admin(user: "models.User") -> bool:
# same question used to exist here and silently disagreed with the scoped one, which
# locked per-project super users out of the routes they were entitled to.
-# Password policy (shared by the API and the CLI).
-MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
-_COMMON_PASSWORDS = {
- "password", "password1", "password123", "passw0rd", "12345678", "123456789",
- "1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
- "iloveyou1", "abc12345", "qwertyuiop",
-}
-
-
-def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
- """Return a human-readable reason the password is unacceptable, or None if OK.
- Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
- if len(pw) < MIN_PASSWORD_LEN:
- return f"Password must be at least {MIN_PASSWORD_LEN} characters."
- low = pw.lower()
- if username and low == username.strip().lower():
- return "Password must not be the same as the username."
- if email and low == email.strip().lower():
- return "Password must not be the same as the email."
- if low in _COMMON_PASSWORDS:
- return "That password is too common — choose something less guessable."
- return None
-
-# Paths under /api that do NOT require a session (login itself, health, docs).
+# Paths under /api that do NOT require a session (the Okta routes themselves, health, docs).
_EXEMPT_PREFIXES = ("/api/auth/",)
_EXEMPT_EXACT = {
"/api/health",
@@ -184,22 +159,6 @@ def _load_secret() -> str:
SECRET_KEY = _load_secret()
-# ── password hashing ──────────────────────────────────────────────────────────
-def hash_password(plain: str) -> str:
- # bcrypt operates on at most 72 bytes; longer inputs are truncated by the
- # algorithm. Encode explicitly so non-ASCII passwords hash consistently.
- return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii")
-
-
-def verify_password(plain: str, hashed: str) -> bool:
- if not hashed:
- return False
- try:
- return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii"))
- except (ValueError, TypeError):
- return False
-
-
# ── tokens ──────────────────────────────────────────────────────────────────
def create_token(user: "models.User") -> str:
now = datetime.now(timezone.utc)
@@ -221,39 +180,15 @@ def decode_token(token: str) -> Optional[dict]:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
- # A password-reset token must never be usable as a session cookie.
+ # No route issues a `typ`-carrying token anymore (that was the password-reset
+ # token, removed in T10.4), but a session token still must never validate as
+ # one — kept as a defensive check, cheap insurance against a future token type
+ # riding the same cookie.
if claims.get("typ"):
return None
return claims
-def create_reset_token(user: "models.User") -> str:
- """Short-lived, single-use token for an emailed password-reset link.
-
- Single-use falls out of `ver`: completing a reset bumps the user's
- token_version, so the link (and any older link) no longer validates."""
- now = datetime.now(timezone.utc)
- payload = {
- "typ": "pwreset",
- "sub": user.id,
- "ver": user.token_version or 0,
- "iat": now,
- "exp": now + timedelta(minutes=RESET_MINUTES),
- }
- return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
-
-
-def decode_reset_token(token: str) -> Optional[dict]:
- """Claims for a valid, unexpired reset token, else None."""
- try:
- claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
- except jwt.PyJWTError:
- return None
- if claims.get("typ") != "pwreset":
- return None
- return claims
-
-
# ── cookie helpers ────────────────────────────────────────────────────────────
def _is_https(request: Request) -> bool:
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,
diff --git a/server/manage_users.py b/server/manage_users.py
index 08280b8..79a11d2 100644
--- a/server/manage_users.py
+++ b/server/manage_users.py
@@ -1,78 +1,59 @@
"""Command-line user management for the Work Package Suite.
-Use this to create the FIRST admin account (the /api/auth/users endpoint needs an
-existing admin, so you have to bootstrap one here), and for occasional account
+There is no local password (D15) and no local account creation from here anymore
+(D16, T10.4) — accounts are created by signing in through Okta, which JIT-
+provisions a row at the lowest-privilege role (see server/okta_auth.py,
+server/app.py's okta_callback(), wave-10.md T10.3). This tool's job is narrower
+now: change the role on an account that already exists, and do routine account
maintenance from a shell on the server.
+That narrower job is still how the very first admin gets named (D16): have that
+person sign in through Okta once — they land as project_user — then promote them
+from here. Promoting an existing row, rather than creating one blind, matters
+because it never has to guess the exact string Okta will send as the identity
+claim; a hand-typed username that doesn't match it exactly would just produce a
+second, orphaned account instead of the one you meant to promote.
+
Run from the PROJECT ROOT (same place you run uvicorn), so the package imports
and .env resolve the same way the API does:
- python -m server.manage_users create-admin alice --name "Alice Smith"
- python -m server.manage_users create bob --role user --name "Bob Jones"
+ python -m server.manage_users promote alice --role admin
python -m server.manage_users list
- python -m server.manage_users reset-password alice
python -m server.manage_users disable bob
python -m server.manage_users enable bob
-
-If --password is omitted you'll be prompted (input is hidden). Passwords must be
-at least 8 characters.
"""
import argparse
-import getpass
import sys
-import uuid
from .db import SessionLocal, Base, engine
from . import models, auth
-def _gen_id() -> str:
- return f"user_{uuid.uuid4().hex[:12]}"
-
-
-def _prompt_password(provided: str | None, username: str = "") -> str:
- pw = provided
- if not pw:
- pw = getpass.getpass("New password: ")
- confirm = getpass.getpass("Confirm password: ")
- if pw != confirm:
- sys.exit("Passwords do not match.")
- problem = auth.password_problem(pw, username)
- if problem:
- sys.exit(problem)
- return pw
-
-
-def cmd_create(args, role: str | None = None) -> None:
- role = role or args.role
- # 'user' is the pre-roles spelling of 'project_user' and is still accepted so the
- # documented one-liners keep working; anything else has to be a current role.
+def cmd_promote(args) -> None:
+ role = args.role
+ # 'user' is the pre-roles spelling of 'project_user', accepted here so a
+ # documented one-liner from before this rework keeps working.
if role == "user":
role = auth.ROLE_PROJECT_USER
if role not in auth.ROLES:
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
- pw = _prompt_password(getattr(args, "password", None), args.username)
with SessionLocal() as db:
- if auth.find_user(db, args.username):
- sys.exit(f"A user named '{args.username}' already exists.")
- u = models.User(
- id=_gen_id(),
- username=args.username.strip(),
- full_name=(args.name or "").strip(),
- email=(args.email or "").strip(),
- password_hash=auth.hash_password(pw),
- role=role,
- )
- db.add(u)
+ u = auth.find_user(db, args.username)
+ if not u:
+ sys.exit(
+ f"No user named '{args.username}'. This promotes an existing account, it "
+ f"doesn't create one — they need to sign in through Okta at least once first."
+ )
+ u.role = role
db.commit()
- print(f"Created {role}: {u.username} (id={u.id})")
+ print(f"{u.username} is now {auth.ROLE_LABELS.get(role, role)}.")
def cmd_list(args) -> None:
with SessionLocal() as db:
rows = db.query(models.User).order_by(models.User.username).all()
if not rows:
- print("No users yet. Create one with: create-admin ")
+ print("No users yet. Accounts appear here once someone signs in through Okta.")
return
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
for u in rows:
@@ -80,17 +61,6 @@ def cmd_list(args) -> None:
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
-def cmd_reset_password(args) -> None:
- pw = _prompt_password(getattr(args, "password", None), args.username)
- with SessionLocal() as db:
- u = auth.find_user(db, args.username)
- if not u:
- sys.exit(f"No user named '{args.username}'.")
- u.password_hash = auth.hash_password(pw)
- db.commit()
- print(f"Password reset for {u.username}.")
-
-
def _set_active(username: str, active: bool) -> None:
with SessionLocal() as db:
u = auth.find_user(db, username)
@@ -108,39 +78,23 @@ def main() -> None:
p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management")
sub = p.add_subparsers(dest="cmd", required=True)
- def add_create(name, help_):
- sp = sub.add_parser(name, help=help_)
- sp.add_argument("username")
- sp.add_argument("--password", help="set non-interactively (otherwise prompted)")
- sp.add_argument("--name", default="", help="full name")
- sp.add_argument("--email", default="")
- return sp
-
- add_create("create-admin", "create an admin account")
- c = add_create("create", "create an account")
- c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER,
- help="permissions role ('user' is the legacy name for project_user)")
+ pr = sub.add_parser("promote", help="change an existing account's role (e.g. name the first admin)")
+ pr.add_argument("username")
+ pr.add_argument("--role", required=True, choices=list(auth.ROLES) + ["user"],
+ help="permissions role ('user' is the legacy name for project_user)")
sub.add_parser("list", help="list all accounts")
- rp = sub.add_parser("reset-password", help="reset a user's password")
- rp.add_argument("username")
- rp.add_argument("--password", help="set non-interactively (otherwise prompted)")
-
- dp = sub.add_parser("disable", help="disable an account (blocks login)")
+ dp = sub.add_parser("disable", help="disable an account (blocks sign-in)")
dp.add_argument("username")
ep = sub.add_parser("enable", help="re-enable an account")
ep.add_argument("username")
args = p.parse_args()
- if args.cmd == "create-admin":
- cmd_create(args, role="admin")
- elif args.cmd == "create":
- cmd_create(args)
+ if args.cmd == "promote":
+ cmd_promote(args)
elif args.cmd == "list":
cmd_list(args)
- elif args.cmd == "reset-password":
- cmd_reset_password(args)
elif args.cmd == "disable":
_set_active(args.username, False)
elif args.cmd == "enable":
diff --git a/server/models.py b/server/models.py
index f841809..05d1b19 100644
--- a/server/models.py
+++ b/server/models.py
@@ -142,8 +142,10 @@ class WorkPackage(Base):
class User(Base):
- """A login account. Passwords are never stored in the clear — only a bcrypt
- hash (see server/auth.py). `username` is what people sign in with.
+ """A login account. No password is stored here or anywhere else — identity is
+ confirmed by Okta (OIDC), this app only decides what the account may do once
+ Okta has vouched for it (see server/okta_auth.py, server/auth.py, D15/D16).
+ `username` is what Okta's identity claim resolves to.
Two independent notions of "role", deliberately separate:
• role the PERMISSIONS role — what the account may do in the app.
@@ -159,7 +161,6 @@ class User(Base):
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
email: Mapped[str] = mapped_column(String(200), default="")
full_name: Mapped[str] = mapped_column(String(200), default="")
- password_hash: Mapped[str] = mapped_column(String(200), default="")
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
# Job function on the project — free text, offered from a suggested list.
project_role: Mapped[str] = mapped_column(String(120), default="")
diff --git a/server/requirements.txt b/server/requirements.txt
index fc4e9a5..adb8568 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -17,8 +17,6 @@ pymssql==2.3.13 # read-only lookups against the Micron asset DB (SQL
# MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server
pydantic==2.13.4
python-dotenv==1.2.2
-bcrypt==5.0.0 # password hashing — still used until T10.4 removes the local
- # password path; do not drop this before that task lands.
PyJWT==2.13.0 # signed session tokens
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)
Authlib==1.7.2 # Okta OIDC authorization-code flow (T10.1, wave 10 / D15)
diff --git a/server/seed_demo.py b/server/seed_demo.py
index 5000b1c..d08a725 100644
--- a/server/seed_demo.py
+++ b/server/seed_demo.py
@@ -9,15 +9,17 @@ and to have data to inspect.
Every /api/ route except /api/health requires a session, so this signs in first and
keeps the session cookie for the rest of the run — the same way server/smoketest.py
-does, reusing its opener rather than growing a second implementation of it.
-Credentials come from the environment so the password never has to appear in a
-command line or shell history:
+does, reusing its opener AND its session-minting (not a second implementation).
+
+There is no local password anymore (D15/D16, T10.4) — see smoketest.py's own
+AUTHENTICATION section for why and what that means: this needs to run where it can
+read the SAME AUTH_SECRET_KEY and reach the SAME database as the server under test,
+and the account must already exist (this seeds a project, not a user).
export WP_SEED_USER= # or WP_SMOKE_USER, which is reused
- export WP_SEED_PASSWORD='…' # or WP_SMOKE_PASSWORD
-…or pass --user / --password. Use an admin account: seeding creates a project, and
---clean deletes one, which needs Project Admin on it.
+…or pass --user. Use an admin account: seeding creates a project, and --clean
+deletes one, which needs Project Admin on it.
USAGE
python3 server/seed_demo.py https://wp-suite.company.local --insecure
@@ -48,16 +50,19 @@ import urllib.error
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+# So `from server import auth, models` / `from server.db import SessionLocal` also
+# resolve (needed to mint a session — see AUTHENTICATION above).
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# The session handling is smoketest.py's, imported rather than copied: one cookie
-# jar implementation, one login flow, one place to fix. Importing is safe — that
-# module does its work under `if __name__ == "__main__"`.
-from smoketest import build_opener # noqa: E402
+# jar implementation, one session-minting flow, one place to fix. Importing is
+# safe — that module does its work under `if __name__ == "__main__"`.
+from smoketest import build_opener, seed_session_cookie # noqa: E402
BASE = ""
CTX = None
-# Carries the cookie jar holding the session issued by /api/auth/login. This
-# script used to call urllib.request.urlopen() directly, which has no cookie
+# Carries the cookie jar holding the minted session (see AUTHENTICATION above).
+# This script used to call urllib.request.urlopen() directly, which has no cookie
# support, so the session was dropped and every data route answered 401 (S13).
OPENER = None
DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data
@@ -116,29 +121,22 @@ def main():
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit")
ap.add_argument("--user", default=os.getenv("WP_SEED_USER", "") or os.getenv("WP_SMOKE_USER", ""),
- help="account to sign in as (default: $WP_SEED_USER, then $WP_SMOKE_USER). "
- "Use an admin account.")
- ap.add_argument("--password",
- default=os.getenv("WP_SEED_PASSWORD", "") or os.getenv("WP_SMOKE_PASSWORD", ""),
- help="its password (default: $WP_SEED_PASSWORD, then $WP_SMOKE_PASSWORD — "
- "preferred, so it stays out of shell history)")
+ help="existing account to sign in as (default: $WP_SEED_USER, then "
+ "$WP_SMOKE_USER). Use an admin account.")
args = ap.parse_args()
BASE = args.base_url.rstrip("/")
if args.insecure:
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
OPENER = build_opener(CTX)
- if not args.user or not args.password:
- missing = " and ".join(n for n, v in (("WP_SEED_USER", args.user),
- ("WP_SEED_PASSWORD", args.password)) if not v)
+ if not args.user:
return abort(
- f"no credentials — {missing} not set.",
+ "no account — $WP_SEED_USER not set.",
" Every /api/ route except /api/health needs a session, so there is nothing\n"
- " this can seed without one. Set them and re-run:\n\n"
- " export WP_SEED_USER=\n"
- " export WP_SEED_PASSWORD='…'\n\n"
- " Or pass --user/--password. WP_SMOKE_USER / WP_SMOKE_PASSWORD are accepted\n"
- " too, so one set of credentials serves this and smoketest.py.")
+ " this can seed without one. Set it and re-run:\n\n"
+ " export WP_SEED_USER=\n\n"
+ " Or pass --user. WP_SMOKE_USER is accepted too, so one account name serves\n"
+ " this and smoketest.py.")
# health gate
try:
@@ -148,18 +146,25 @@ def main():
if st != 200:
print(f"ABORT: /api/health returned {st}"); return 1
- # Sign in. The cookie the response sets is held by OPENER's jar and rides every
- # request after this one.
- st, body = call("POST", "/api/auth/login",
- {"username": args.user, "password": args.password})
- if st != 200:
- detail = body.get("detail") if isinstance(body, dict) else body
- hint = (" The account may be locked: the API locks an account for a while after a\n"
- " few consecutive failures, so retrying with the wrong password makes this\n"
- " worse. Check the password, then wait out the lockout window."
- if st in (401, 403, 423, 429) else
- " Unexpected status from the login endpoint — check the API logs.")
- return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint)
+ # "Sign in" — mint a session directly (see AUTHENTICATION above) and seed it
+ # into OPENER's jar, so it rides every request after this one.
+ try:
+ from server import auth as srv_auth
+ from server.db import SessionLocal
+ except ImportError as e:
+ return abort(f"cannot import the server package to mint a session: {e}",
+ " This needs to run where server/ is importable and AUTH_SECRET_KEY /\n"
+ " DATABASE_URL match the target server's — see AUTHENTICATION above.")
+ with SessionLocal() as db:
+ user = srv_auth.find_user(db, args.user)
+ if not user:
+ return abort(f"no account named '{args.user}'.",
+ " This signs in as an existing account, it doesn't create one — sign in\n"
+ " through Okta once first, or create it from the admin console.")
+ if not user.is_active:
+ return abort(f"'{args.user}' is disabled.", "")
+ token = srv_auth.create_token(user)
+ seed_session_cookie(token, BASE)
logged_in = True
print(f"Signed in as {args.user}.")
diff --git a/server/smoketest.py b/server/smoketest.py
index ea29323..a9598b7 100644
--- a/server/smoketest.py
+++ b/server/smoketest.py
@@ -6,16 +6,23 @@ NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
AUTHENTICATION
- Every /api/ route except /api/health requires a session (auth_gate in
- server/app.py), so the script signs in first and keeps the session cookie for
- the rest of the run. Credentials come from the environment by preference, so a
- password never has to appear in a command line or shell history:
+ There is no local password anymore (D15/D16, T10.4) — identity is Okta's job,
+ and Okta requires a real browser to complete, which this stdlib script cannot
+ do. So instead of signing in over HTTP the way the front end does, this script
+ mints a session the same way server/app.py's okta_callback() does after Okta
+ hands back an identity: auth.create_token() for an existing account, seeded
+ straight into the cookie jar. That means it needs to run somewhere that can
+ read the SAME AUTH_SECRET_KEY and reach the SAME database as the server under
+ test — inside the api container, or locally against your dev DB. It can no
+ longer sign in to an arbitrary remote URL from an unrelated workstation; if
+ the target is remote, run it on that host or inside that container instead.
export WP_SMOKE_USER=smoketest
- export WP_SMOKE_PASSWORD='…'
python3 server/smoketest.py https://wp-suite.company.local
- …or pass --user / --password explicitly.
+ …or pass --user explicitly. The account must already exist — sign it in
+ through Okta once first (or create it from the admin console) if it doesn't;
+ this script promotes no one and provisions nothing.
Use an ADMIN account. The script creates a project and deletes it again at the
end, and deleting one takes Project Admin on that project (require_project_admin);
@@ -24,26 +31,29 @@ AUTHENTICATION
discover it in the cleanup step.
USAGE
- # Against the deployed site (through the NGINX proxy):
- python3 server/smoketest.py https://wp-suite.company.local
-
- # Self-signed / internal TLS cert? skip verification:
- python3 server/smoketest.py https://wp-suite.company.local --insecure
-
- # From inside the api container (hits FastAPI directly):
- docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
+ # From inside the api container (has AUTH_SECRET_KEY and DATABASE_URL; hits
+ # FastAPI directly):
+ docker compose exec -e WP_SMOKE_USER api \
python /app/server/smoketest.py http://localhost:8000
+ # Local dev, against the app you're running yourself:
+ export AUTH_SECRET_KEY=... DATABASE_URL=... WP_SMOKE_USER=smoketest
+ python3 server/smoketest.py http://localhost:8000
+
+ # Self-signed / internal TLS cert on the HTTP side? skip verification:
+ python3 server/smoketest.py https://wp-suite.company.local --insecure
+
# Leave the demo project in the database so you can open it in the UI:
- python3 server/smoketest.py https://wp-suite.company.local --keep
+ python3 server/smoketest.py http://localhost:8000 --keep
The base URL is the SITE root (no /api). Default: http://localhost:8000
Exit codes: 0 = all checks passed · 1 = one or more checks failed · 2 = the run
-could not start (unreachable host, missing or rejected credentials). 2 is kept
-distinct on purpose: "I could not test this" is not the same answer as "this is
-broken", and conflating them is what made an unauthenticated version of this
-script report a wall of failures against a perfectly healthy stack.
+could not start (unreachable host, missing credentials, or no account by that
+username). 2 is kept distinct on purpose: "I could not test this" is not the
+same answer as "this is broken", and conflating them is what made an
+unauthenticated version of this script report a wall of failures against a
+perfectly healthy stack.
"""
import argparse
import http.cookiejar
@@ -53,6 +63,12 @@ import ssl
import sys
import urllib.error
import urllib.request
+from urllib.parse import urlparse
+
+# So `from server import auth, models` / `from server.db import SessionLocal` resolve
+# when this file is run directly (`python3 server/smoketest.py`) rather than as
+# `python -m server.smoketest` — same reasoning as the sys.path lines in tests/*.py.
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ── tiny colored reporter ─────────────────────────────────────────────────────
_PASS, _FAIL = [], []
@@ -66,19 +82,40 @@ def check(name, cond, detail=""):
BASE = ""
CTX = None
-# One opener for the whole run, carrying the cookie jar that holds the session
-# issued by /api/auth/login. urlopen() has no cookie support, which is why the
-# session used to be dropped on the floor and every data route answered 401.
+# One opener for the whole run, carrying the cookie jar that holds the session.
+# urlopen() has no cookie support, which is why the session used to be dropped on
+# the floor and every data route answered 401.
OPENER = None
+COOKIE_JAR = None
def build_opener(ctx=None):
- handlers = [urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())]
+ global COOKIE_JAR
+ COOKIE_JAR = http.cookiejar.CookieJar()
+ handlers = [urllib.request.HTTPCookieProcessor(COOKIE_JAR)]
if ctx is not None:
handlers.append(urllib.request.HTTPSHandler(context=ctx))
return urllib.request.build_opener(*handlers)
+def seed_session_cookie(token: str, base: str) -> None:
+ """Put a minted session into the jar directly, the same shape a Set-Cookie
+ response from the old /api/auth/login would have produced — so the logout
+ check below (which relies on the jar honoring logout()'s Set-Cookie that
+ expires it) keeps working unchanged. `base` is explicit rather than read off
+ this module's own BASE global, so seed_demo.py (which imports this function
+ but has its own BASE) seeds the cookie for the host it's actually targeting."""
+ host = urlparse(base).hostname or "localhost"
+ COOKIE_JAR.set_cookie(http.cookiejar.Cookie(
+ version=0, name="wp_session", value=token,
+ port=None, port_specified=False,
+ domain=host, domain_specified=True, domain_initial_dot=False,
+ path="/", path_specified=True,
+ secure=False, expires=None, discard=True,
+ comment=None, comment_url=None, rest={"HttpOnly": None},
+ ))
+
+
def call(method, path, body=None):
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
url = BASE + path
@@ -116,10 +153,7 @@ def main():
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
ap.add_argument("--user", default=os.getenv("WP_SMOKE_USER", ""),
- help="account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
- ap.add_argument("--password", default=os.getenv("WP_SMOKE_PASSWORD", ""),
- help="its password (default: $WP_SMOKE_PASSWORD — preferred, "
- "so it stays out of shell history)")
+ help="existing account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
args = ap.parse_args()
BASE = args.base_url.rstrip("/")
if args.insecure:
@@ -128,17 +162,14 @@ def main():
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
- # Refuse to start without credentials rather than running headlong into 401s.
- if not args.user or not args.password:
- missing = " and ".join(
- n for n, v in (("WP_SMOKE_USER", args.user), ("WP_SMOKE_PASSWORD", args.password)) if not v)
+ # Refuse to start without a username rather than running headlong into 401s.
+ if not args.user:
return abort(
- f"no credentials — {missing} not set.",
+ "no account — $WP_SMOKE_USER not set.",
" Every /api/ route except /api/health needs a session, so there is nothing\n"
- " meaningful to test without one. Set them and re-run:\n\n"
- " export WP_SMOKE_USER=\n"
- " export WP_SMOKE_PASSWORD='…'\n\n"
- " Or pass --user/--password. Use an admin account: the run creates a project\n"
+ " meaningful to test without one. Set it and re-run:\n\n"
+ " export WP_SMOKE_USER=\n\n"
+ " Or pass --user. Use an admin account: the run creates a project\n"
" and deletes it again, and the delete needs Project Admin on it.")
project_id = None
@@ -157,21 +188,29 @@ def main():
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
f"status={st} body={body}")
- # 2) Sign in. The cookie the response sets is held by OPENER's jar and rides
- # every request after this one.
- st, body = call("POST", "/api/auth/login",
- {"username": args.user, "password": args.password})
- if st != 200:
- detail = body.get("detail") if isinstance(body, dict) else body
- hint = (" The account may be locked: the API locks an account for a while after\n"
- " a few consecutive failures (AUTH_MAX_ATTEMPTS / AUTH_LOCKOUT_MINUTES),\n"
- " so re-running with the wrong password makes this worse, not better.\n"
- " Check the password, then wait out the lockout window."
- if st in (401, 403, 423, 429) else
- " Unexpected status from the login endpoint — check the API logs.")
- return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint)
+ # 2) "Sign in" — mint a session directly (see AUTHENTICATION above) and seed
+ # it into OPENER's jar, so it rides every request after this one exactly the
+ # way a real Set-Cookie response would have.
+ try:
+ from server import auth as srv_auth
+ from server.db import SessionLocal
+ except ImportError as e:
+ return abort(f"cannot import the server package to mint a session: {e}",
+ " This script now needs to run where server/ is importable and\n"
+ " AUTH_SECRET_KEY / DATABASE_URL match the target server's — see\n"
+ " AUTHENTICATION above.")
+ with SessionLocal() as db:
+ user = srv_auth.find_user(db, args.user)
+ if not user:
+ return abort(f"no account named '{args.user}'.",
+ " This script signs in as an existing account, it doesn't create one —\n"
+ " sign in through Okta once first, or create it from the admin console.")
+ if not user.is_active:
+ return abort(f"'{args.user}' is disabled.", "")
+ token = srv_auth.create_token(user)
+ seed_session_cookie(token, BASE)
logged_in = True
- check("login issues a session", st == 200)
+ check("session cookie seeded", bool(token))
# 3) Prove the session actually travels — this is the check whose absence let
# an unauthenticated version of this script look like a broken stack.
diff --git a/tests/browser_check.py b/tests/browser_check.py
index 6870898..99c7246 100644
--- a/tests/browser_check.py
+++ b/tests/browser_check.py
@@ -39,7 +39,6 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
-PW = "CorrectHorseBattery9"
_PASS, _FAIL = [], []
@@ -86,7 +85,7 @@ def seed(db_path):
def mk(username, role):
db.add(models.User(id="user_" + username, username=username,
email=f"{username}@example.test", full_name=username.title(),
- password_hash=auth.hash_password(PW), role=role))
+ role=role))
mk("root", auth.ROLE_ADMIN)
mk("sue", auth.ROLE_PROJECT_SUPER) # super user on Job A
@@ -436,7 +435,10 @@ def main():
finally:
if args.keep_server:
print(f"\n --keep-server: still up at {base}, database at {db_path}")
- print(" Sign in as root / " + PW)
+ # No local password exists (D15/D16) — there's nothing to type into a login
+ # form. Set the session cookie directly, the same way this script's own
+ # fixture does, from the browser console on that origin:
+ print(f" document.cookie = 'wp_session={tok['root']}; path=/'")
else:
if server:
# Wait for it to actually exit before deleting the database out from
diff --git a/tests/console_dialogs_check.py b/tests/console_dialogs_check.py
index 346db79..742f611 100644
--- a/tests/console_dialogs_check.py
+++ b/tests/console_dialogs_check.py
@@ -8,9 +8,17 @@ They now go through `wp-dialog.js` — the T7.9 kit extracted as a shared,
self-injecting component (guarded so the creator's inline copy still wins on
its own page).
-Static half greps the counts; browser half drives the password-reset prompt on
-the users console with natives poisoned, and proves validate() answers AT the
-input while the server round-trip completes end to end.
+Static half greps the counts; browser half drives the users console's dialogs
+with natives poisoned and proves they still work end to end.
+
+Used to drive this via the password-reset prompt specifically, because it was
+the one place on this page exercising wp-dialog.js's PROMPT variant (text input
++ client-side validate()) rather than its confirm variant. T10.4 (D15/D16)
+removed admin password reset entirely — there is no password to reset anymore
+— so that coverage moved with it. The prompt-with-validate() pattern itself is
+still exercised, just not on this page: see creator_dialogs_check.py for
+wp-creation-app.js's own wpPromptDialog() call sites. If users.html ever grows
+a new prompt-style dialog, it belongs back in this file.
Boots its own throwaway SQLite + uvicorn + headless browser; run it alone.
Exit 0 all passed, 1 a failure, 2 could not run.
@@ -91,31 +99,6 @@ def main():
chk("the console booted with a user table",
page.eval("!!document.querySelector('table')"))
- page.eval("void resetPw('user_pat','pat')")
- time.sleep(0.4)
- chk("the reset prompt is the kit's modal, open, focused at the input",
- page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
- " return !!o && o.classList.contains('open')"
- " && document.activeElement.id==='wp-dlg-input'; })()"))
- page.eval("document.getElementById('wp-dlg-input').value='short';"
- "document.getElementById('wp-dlg-ok').click()")
- chk("a short password is refused AT the input - dialog stays, error says why",
- page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
- " return o.classList.contains('open')"
- " && /12 characters/.test(document.getElementById('wp-dlg-err').textContent); })()"))
- page.eval("document.getElementById('wp-dlg-input').value='CorrectHorseBattery10';"
- "document.getElementById('wp-dlg-ok').click()")
- time.sleep(1.2)
- chk("a good answer closes the dialog and the server accepts it",
- page.eval("!document.getElementById('wp-dlg-overlay').classList.contains('open')"))
- chk("...announced through the kit's toast (role=status)",
- page.eval("(() => { const t=document.getElementById('toast');"
- " return !!t && t.getAttribute('role')==='status'"
- " && /Password reset for pat/.test(t.textContent); })()"))
- st, _ = api(base, "/api/auth/login", "x", "POST",
- {"username": "pat", "password": "CorrectHorseBattery10"})
- chk("...and the new password actually works", st == 200, st)
-
print("\n3. destroy needs a real yes")
page.eval("void deleteUser('user_bob','bob')")
time.sleep(0.4)
diff --git a/tests/launcher_check.py b/tests/launcher_check.py
index 9643091..3628edc 100644
--- a/tests/launcher_check.py
+++ b/tests/launcher_check.py
@@ -31,7 +31,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
-from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402
+from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
STUB = """
window.__dialogs = [];
@@ -55,8 +55,7 @@ def seed_empty(db_path):
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
db.add(models.User(id="user_new", username="new", email="new@example.test",
- full_name="New Starter", password_hash=auth.hash_password(PW),
- role=auth.ROLE_ADMIN))
+ full_name="New Starter", role=auth.ROLE_ADMIN))
db.commit()
return {u.username: auth.create_token(u) for u in db.query(models.User).all()}
diff --git a/tests/pipeline_check.py b/tests/pipeline_check.py
index cfdb5c4..e1dedb2 100644
--- a/tests/pipeline_check.py
+++ b/tests/pipeline_check.py
@@ -33,7 +33,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
-from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402
+from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
READY = "!!document.querySelector('#pipeline-strip .pipe-cell, #pipeline-strip .pipe-empty, " \
"#pipeline-strip .pipe-error')"
diff --git a/tests/token_check.py b/tests/token_check.py
index 83c172b..81fb04b 100644
--- a/tests/token_check.py
+++ b/tests/token_check.py
@@ -47,7 +47,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
-from browser_check import seed, start_server, PW # noqa: E402,F401
+from browser_check import seed, start_server # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
diff --git a/tests/url_state_check.py b/tests/url_state_check.py
index 436b294..82dcd7f 100644
--- a/tests/url_state_check.py
+++ b/tests/url_state_check.py
@@ -8,6 +8,10 @@ will rest on.
1. a URL identifying a work package opens that work package
2. the same URL works for a SIGNED-OUT user, via login, landing on the target
+ — SKIPPED as of T10.4: local login is gone (D15/D16), and the redirect-
+ through-Okta replacement doesn't exist until T10.5 rebuilds login.html.
+ Re-test this once that lands. Signing in via a minted token stands in as
+ setup only, so scenarios 3-6 below still get a signed-in page to run on.
3. refresh preserves project, package, tab and view
4. Back and Forward move through states without a reload or a broken view
5. the URL survives being copied to a second browsing context
@@ -27,7 +31,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
-from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402
+from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
def settle(page, seconds=1.4):
@@ -169,6 +173,8 @@ def main():
page2.close()
print("\n2. the same URL works for a signed-out user, via login")
+ print(" SKIPPED: local login is gone (D15/D16); the Okta-redirect replacement")
+ print(" doesn't exist until T10.5. Re-test the next= round trip once it lands.")
page.clear_cookies()
page.goto(deep)
settle(page, 1.6)
@@ -177,19 +183,14 @@ def main():
nxt = page.eval("new URLSearchParams(location.search).get('next')||''")
chk("...carrying the requested target, package id and all",
"wp-creation-index.html" in nxt and "wp=wpA1" in nxt, "next=%r" % nxt)
- page.eval("document.getElementById('username').value=%r" % "root")
- page.eval("document.getElementById('password').value=%r" % PW)
- page.eval("document.querySelector('form').requestSubmit"
- "? document.querySelector('form').requestSubmit()"
- ": document.querySelector('form').submit()")
- for _ in range(40):
- if "wp-creation-index.html" in page.eval("location.href"):
- break
- time.sleep(0.3)
- settle(page, 1.2)
- chk("signing in continues to the requested page, not the home page",
- "wp-creation-index.html" in page.eval("location.href"),
- page.eval("location.href"))
+ # Setup only for scenarios 3-6 below, NOT a re-test of "signing in continues
+ # to the requested page" — that promise is specific to the login FORM this
+ # task removed, and can't be honestly re-proven until T10.5 rebuilds it as an
+ # Okta redirect. A minted-token cookie gets `page` to the same signed-in,
+ # on-target state those later scenarios need, without claiming to have
+ # exercised the (currently nonexistent) sign-in flow itself.
+ page.set_cookie("wp_session", tok["root"])
+ page.goto(deep)
for _ in range(30):
if page.eval("!!window.wpCreatorReady"):
break