feat(external): permit login via email (magic link)
This commit is contained in:
@@ -249,6 +249,152 @@ impl MagicLinkInviteService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Login-via-email flow (PR 10). Caller submits an email at
|
||||||
|
/// `/login`; we look it up — **never lazy-create** here, that path
|
||||||
|
/// is reserved for `resolve_or_create_recipient` — and if the
|
||||||
|
/// matched user has no other login credential, mint a NULL-resource
|
||||||
|
/// magic-link token and email a sign-in link. The redemption
|
||||||
|
/// endpoint lands a NULL-resource token on `/#/sharedwithme`.
|
||||||
|
///
|
||||||
|
/// Always returns `Ok(())` so the caller can emit a uniform
|
||||||
|
/// response shape (`"If an account exists, a link will be sent."`)
|
||||||
|
/// that doesn't reveal whether the email maps to an account.
|
||||||
|
///
|
||||||
|
/// Audit log distinguishes three real outcomes — `sent`,
|
||||||
|
/// `no_account`, `has_credential` — so operators can see the truth
|
||||||
|
/// while the API stays anti-enumeration-safe. A fourth outcome
|
||||||
|
/// `send_failed` is logged at `warn` level when SMTP errors.
|
||||||
|
pub async fn send_login_link(&self, raw_email: &str) -> Result<(), DomainError> {
|
||||||
|
let normalised = match normalize_email(raw_email) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
// Malformed input is treated the same as "no account"
|
||||||
|
// — uniform response, no oracle from validation errors.
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "auth.magic_link_send",
|
||||||
|
reason = "malformed_email",
|
||||||
|
error = %e,
|
||||||
|
"🔗 login-link suppressed: malformed email",
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = match UserRepository::get_user_by_email(&*self.user_storage, &normalised).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(UserRepositoryError::NotFound(_)) => {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "auth.magic_link_send",
|
||||||
|
reason = "no_account",
|
||||||
|
email = %normalised,
|
||||||
|
"🔗 login-link suppressed: no account for '{}'",
|
||||||
|
normalised,
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => return Err(DomainError::from(e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if user.has_login_credential() {
|
||||||
|
// Refuse the magic-link path for users with a password /
|
||||||
|
// OIDC — accepting it would let an attacker bypass those
|
||||||
|
// factors by merely owning the mailbox at the moment of
|
||||||
|
// request. They should sign in through the regular form.
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "auth.magic_link_send",
|
||||||
|
reason = "has_credential",
|
||||||
|
user_id = %user.id(),
|
||||||
|
username = %user.username(),
|
||||||
|
email = %normalised,
|
||||||
|
"🔗 login-link suppressed: '{}' has another login credential",
|
||||||
|
user.username(),
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.is_active() {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "auth.magic_link_send",
|
||||||
|
reason = "account_deactivated",
|
||||||
|
user_id = %user.id(),
|
||||||
|
username = %user.username(),
|
||||||
|
email = %normalised,
|
||||||
|
"🔗 login-link suppressed: account deactivated for '{}'",
|
||||||
|
user.username(),
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint a NULL-resource token. The redemption handler lands
|
||||||
|
// NULL-resource tokens on /#/sharedwithme (see PR 8).
|
||||||
|
let token = MagicLinkToken::new(user.id(), self.magic_link_cfg.ttl_hours, None);
|
||||||
|
self.magic_link_repo.create(&token).await?;
|
||||||
|
|
||||||
|
let link = format!(
|
||||||
|
"{}/magic/v1/{}",
|
||||||
|
self.public_base_url.trim_end_matches('/'),
|
||||||
|
token.token(),
|
||||||
|
);
|
||||||
|
let subject = "Sign in to OxiCloud".to_string();
|
||||||
|
let text_body = format!(
|
||||||
|
"Hello,\n\
|
||||||
|
\n\
|
||||||
|
Use the link below to sign in to OxiCloud. The link works \
|
||||||
|
once and expires in {ttl} hours.\n\
|
||||||
|
\n\
|
||||||
|
{link}\n\
|
||||||
|
\n\
|
||||||
|
If you didn't request this sign-in link, you can safely \
|
||||||
|
ignore this message — no further action is needed.\n\
|
||||||
|
\n\
|
||||||
|
— OxiCloud, {now}\n",
|
||||||
|
ttl = self.magic_link_cfg.ttl_hours,
|
||||||
|
link = link,
|
||||||
|
now = Utc::now().to_rfc3339(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let message = EmailMessage {
|
||||||
|
to: user.email().to_string(),
|
||||||
|
subject,
|
||||||
|
text_body,
|
||||||
|
html_body: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.email_sender.send(message).await {
|
||||||
|
Ok(outcome) => {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "auth.magic_link_send",
|
||||||
|
reason = "sent",
|
||||||
|
user_id = %user.id(),
|
||||||
|
username = %user.username(),
|
||||||
|
email = %normalised,
|
||||||
|
smtp_code = outcome.code,
|
||||||
|
smtp_message = %outcome.message,
|
||||||
|
"🔗 login-link sent to '{}'",
|
||||||
|
normalised,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "audit",
|
||||||
|
event = "auth.magic_link_send_failed",
|
||||||
|
user_id = %user.id(),
|
||||||
|
email = %normalised,
|
||||||
|
error = %e.message,
|
||||||
|
"🔗 login-link SMTP send failed for '{}'",
|
||||||
|
normalised,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lightweight conversion so the grant handler can derive a
|
/// Lightweight conversion so the grant handler can derive a
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ pub fn auth_public_routes() -> Router<Arc<AppState>> {
|
|||||||
.route("/oidc/authorize", get(oidc_authorize))
|
.route("/oidc/authorize", get(oidc_authorize))
|
||||||
.route("/oidc/callback", get(oidc_callback))
|
.route("/oidc/callback", get(oidc_callback))
|
||||||
.route("/oidc/exchange", post(oidc_exchange))
|
.route("/oidc/exchange", post(oidc_exchange))
|
||||||
|
// Login-via-email — sends a magic-link to the user's email so
|
||||||
|
// accounts with no other login credential can sign in.
|
||||||
|
.route("/magic-link/send", post(send_magic_link))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Protected auth routes — require authentication (auth + CSRF middleware
|
/// Protected auth routes — require authentication (auth + CSRF middleware
|
||||||
@@ -890,3 +893,62 @@ pub async fn oidc_exchange(
|
|||||||
cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in);
|
cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in);
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request body for `POST /api/auth/magic-link/send`.
|
||||||
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct SendMagicLinkDto {
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/auth/magic-link/send — request a sign-in link by email.
|
||||||
|
///
|
||||||
|
/// Always returns 200 with a uniform message regardless of outcome, so
|
||||||
|
/// the response shape doesn't leak account existence. The real outcome
|
||||||
|
/// (sent / no-account / has-credential / account-deactivated /
|
||||||
|
/// malformed-email) is recorded in the `audit` channel via
|
||||||
|
/// `MagicLinkInviteService::send_login_link`.
|
||||||
|
///
|
||||||
|
/// 503 only when the magic-link feature isn't configured at all
|
||||||
|
/// (SMTP env missing) — operators need to know about misconfiguration;
|
||||||
|
/// it's not a state an anonymous caller can probe via timing because
|
||||||
|
/// the absence of the entire feature is visible from any other
|
||||||
|
/// endpoint touching `/api/auth/magic-link/*`.
|
||||||
|
///
|
||||||
|
/// Per-target-email rate limit is scheduled for PR 12 — without it,
|
||||||
|
/// the endpoint could be used as an email-bombing primitive against a
|
||||||
|
/// known recipient address.
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/auth/magic-link/send",
|
||||||
|
request_body = SendMagicLinkDto,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Uniform 'if an account exists, a link will be sent' response"),
|
||||||
|
(status = 503, description = "Magic-link / SMTP is not configured on this server"),
|
||||||
|
),
|
||||||
|
tag = "auth",
|
||||||
|
)]
|
||||||
|
pub async fn send_magic_link(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(body): Json<SendMagicLinkDto>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let Some(invite_svc) = state.magic_link_invite_service.as_ref() else {
|
||||||
|
return Err(AppError::new(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Magic-link sign-in is not configured on this server",
|
||||||
|
"ServiceUnavailable",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// The service swallows every operational outcome and logs the truth
|
||||||
|
// via the audit channel; we surface only an internal error (DB down,
|
||||||
|
// etc.). Anti-enumeration means we always return the same body.
|
||||||
|
invite_svc
|
||||||
|
.send_login_link(&body.email)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"message": "If an account exists for that email, a sign-in link will be sent.",
|
||||||
|
});
|
||||||
|
Ok((StatusCode::OK, Json(payload)).into_response())
|
||||||
|
}
|
||||||
|
|||||||
@@ -183,6 +183,36 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Helper text above the magic-link form ("No password? Enter your
|
||||||
|
email…"). Quieter visual weight than the form labels. */
|
||||||
|
.auth-hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status banner under the magic-link form. Uniform anti-enumeration
|
||||||
|
message rendered on every successful 2xx; error variant only used
|
||||||
|
for the 503-not-configured branch or network failures. */
|
||||||
|
.auth-status {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.auth-status-success {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-left: 3px solid var(--color-warning-orange-text);
|
||||||
|
}
|
||||||
|
.auth-status-error {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-left: 3px solid var(--color-warning-orange-text);
|
||||||
|
}
|
||||||
|
|
||||||
/* Divider between password and SSO login */
|
/* Divider between password and SSO login */
|
||||||
.auth-divider {
|
.auth-divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1072,6 +1072,54 @@ if (isLoginPage && registerForm) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Admin setup form submission
|
// Admin setup form submission
|
||||||
|
// Magic-link form: anti-enumeration sign-in by email.
|
||||||
|
// The server always responds 200 with a uniform message when SMTP is
|
||||||
|
// configured, regardless of whether the email maps to an account or
|
||||||
|
// whether that account is eligible for magic-link sign-in. The UI
|
||||||
|
// mirrors that — same success state for every successful 2xx — so
|
||||||
|
// the page can't be used as an oracle. 503 is the one exception
|
||||||
|
// (SMTP not configured); operators need to see it.
|
||||||
|
const magicLinkForm = /** @type {HTMLFormElement | null} */ (document.getElementById('magic-link-form'));
|
||||||
|
const magicLinkStatus = document.getElementById('magic-link-status');
|
||||||
|
const magicLinkSubmit = /** @type {HTMLButtonElement | null} */ (document.getElementById('magic-link-submit'));
|
||||||
|
if (isLoginPage && magicLinkForm && magicLinkStatus) {
|
||||||
|
magicLinkForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const email = inputVal('magic-link-email');
|
||||||
|
if (!email) return;
|
||||||
|
|
||||||
|
magicLinkStatus.className = 'auth-status hidden';
|
||||||
|
magicLinkStatus.textContent = '';
|
||||||
|
if (magicLinkSubmit) magicLinkSubmit.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/magic-link/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
});
|
||||||
|
if (resp.status === 503) {
|
||||||
|
magicLinkStatus.className = 'auth-status auth-status-error';
|
||||||
|
magicLinkStatus.textContent = i18n.t('auth.magicLinkUnavailable', 'Sign-in by email is not available on this server.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Any 2xx → uniform success message regardless of whether
|
||||||
|
// the server actually queued a mail. Anti-enumeration.
|
||||||
|
magicLinkStatus.className = 'auth-status auth-status-success';
|
||||||
|
magicLinkStatus.textContent = i18n.t('auth.magicLinkSent', 'If an account exists for that email, a sign-in link has been sent. Check your inbox.');
|
||||||
|
magicLinkForm.reset();
|
||||||
|
} catch (err) {
|
||||||
|
magicLinkStatus.className = 'auth-status auth-status-error';
|
||||||
|
magicLinkStatus.textContent = i18n.t('auth.magicLinkNetworkError', {
|
||||||
|
message: /** @type {Error} */ (err).message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (magicLinkSubmit) magicLinkSubmit.disabled = false;
|
||||||
|
magicLinkStatus.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoginPage && adminSetupForm) {
|
if (isLoginPage && adminSetupForm) {
|
||||||
adminSetupForm.addEventListener('submit', async (e) => {
|
adminSetupForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -412,7 +412,14 @@
|
|||||||
"admin_create_error": "Error creating administrator account",
|
"admin_create_error": "Error creating administrator account",
|
||||||
"or": "or",
|
"or": "or",
|
||||||
"sso_login": "Sign in with SSO",
|
"sso_login": "Sign in with SSO",
|
||||||
"sso_login_provider": "Sign in with {{provider}}"
|
"sso_login_provider": "Sign in with {{provider}}",
|
||||||
|
"magicLinkHint": "No password? Enter your email and we'll send you a one-time sign-in link.",
|
||||||
|
"magicLinkEmailLabel": "Email address",
|
||||||
|
"magicLinkEmailPlaceholder": "you@example.com",
|
||||||
|
"magicLinkSubmit": "Send sign-in link",
|
||||||
|
"magicLinkSent": "If an account exists for that email, a sign-in link has been sent. Check your inbox.",
|
||||||
|
"magicLinkUnavailable": "Sign-in by email is not available on this server.",
|
||||||
|
"magicLinkNetworkError": "Could not reach the server: {{message}}"
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"title": "Storage",
|
"title": "Storage",
|
||||||
|
|||||||
@@ -412,7 +412,14 @@
|
|||||||
"admin_create_error": "Erreur lors de la création du compte administrateur",
|
"admin_create_error": "Erreur lors de la création du compte administrateur",
|
||||||
"or": "ou",
|
"or": "ou",
|
||||||
"sso_login": "Se connecter avec SSO",
|
"sso_login": "Se connecter avec SSO",
|
||||||
"sso_login_provider": "Se connecter avec {{provider}}"
|
"sso_login_provider": "Se connecter avec {{provider}}",
|
||||||
|
"magicLinkHint": "Pas de mot de passe ? Saisissez votre adresse e-mail et nous vous enverrons un lien de connexion à usage unique.",
|
||||||
|
"magicLinkEmailLabel": "Adresse e-mail",
|
||||||
|
"magicLinkEmailPlaceholder": "vous@exemple.com",
|
||||||
|
"magicLinkSubmit": "Envoyer le lien de connexion",
|
||||||
|
"magicLinkSent": "Si un compte existe pour cette adresse, un lien de connexion vient d'être envoyé. Consultez votre boîte de réception.",
|
||||||
|
"magicLinkUnavailable": "La connexion par e-mail n'est pas disponible sur ce serveur.",
|
||||||
|
"magicLinkNetworkError": "Impossible de joindre le serveur : {{message}}"
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"title": "Stockage",
|
"title": "Stockage",
|
||||||
|
|||||||
@@ -108,6 +108,42 @@
|
|||||||
<span data-i18n="auth.sso_login">Sign in with SSO</span>
|
<span data-i18n="auth.sso_login">Sign in with SSO</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sign-in by email link (magic-link). For users without a
|
||||||
|
password (external recipients invited via share, future
|
||||||
|
passwordless-only accounts). Always rendered — the
|
||||||
|
endpoint is anti-enumeration so showing it doesn't leak
|
||||||
|
anything; if SMTP isn't configured the server returns
|
||||||
|
503 and we show a generic "unavailable" message. -->
|
||||||
|
<div id="magic-link-section">
|
||||||
|
<div class="auth-divider">
|
||||||
|
<span data-i18n="auth.or">or</span>
|
||||||
|
</div>
|
||||||
|
<p class="auth-hint" data-i18n="auth.magicLinkHint">
|
||||||
|
No password? Enter your email and we'll send you a one-time sign-in link.
|
||||||
|
</p>
|
||||||
|
<form class="auth-form" id="magic-link-form">
|
||||||
|
<div class="auth-input-group">
|
||||||
|
<label class="auth-label" for="magic-link-email" data-i18n="auth.magicLinkEmailLabel">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="magic-link-email"
|
||||||
|
class="auth-input"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
data-i18n-placeholder="auth.magicLinkEmailPlaceholder"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="auth-button" id="magic-link-submit" data-i18n="auth.magicLinkSubmit">
|
||||||
|
Send sign-in link
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div id="magic-link-status" class="auth-status hidden" role="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="auth-toggle">
|
<div class="auth-toggle">
|
||||||
<span data-i18n="auth.no_account">Don't have an account?</span>
|
<span data-i18n="auth.no_account">Don't have an account?</span>
|
||||||
|
|||||||
@@ -274,6 +274,86 @@ GET {{magic_url}}
|
|||||||
HTTP 410
|
HTTP 410
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 15 — Login-via-email (PR 10). Bob has no password (he was
|
||||||
|
# lazily provisioned via the invite flow), so he is
|
||||||
|
# magic-link-eligible. He requests a fresh sign-in link.
|
||||||
|
# Anti-enumeration: the API always returns 200 with the
|
||||||
|
# same body regardless of whether an account exists.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# 15a — bob requests a sign-in link.
|
||||||
|
POST {{base_url}}/api/auth/magic-link/send
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "email": "bob@externalcompany.com" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.message" contains "sign-in link"
|
||||||
|
|
||||||
|
# 15b — Capture the fresh email; extract the NEW magic-link URL.
|
||||||
|
# This is a NULL-resource token (login flow), so redemption
|
||||||
|
# will land on /#/sharedwithme rather than a deep-link.
|
||||||
|
GET {{base_url}}/api/admin/smtp/test/captured?to=bob@externalcompany.com
|
||||||
|
Authorization: Bearer {{alice_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.subject" contains "Sign in"
|
||||||
|
jsonpath "$.text_body" matches "/magic/v1/[A-Za-z0-9_-]+"
|
||||||
|
[Captures]
|
||||||
|
login_magic_url: jsonpath "$.text_body" regex "(https?://[^\\s]+/magic/v1/[A-Za-z0-9_-]+)"
|
||||||
|
|
||||||
|
# 15c — Redeem the login link. Lands on /#/sharedwithme since the
|
||||||
|
# token has no resource target.
|
||||||
|
GET {{login_magic_url}}
|
||||||
|
|
||||||
|
HTTP 302
|
||||||
|
[Asserts]
|
||||||
|
header "Location" == "/#/sharedwithme"
|
||||||
|
[Captures]
|
||||||
|
bob_relogin_token: cookie "oxicloud_access"
|
||||||
|
|
||||||
|
# 15d — Bob's new session works: he can read his incoming grants.
|
||||||
|
GET {{base_url}}/api/grants/incoming/resources
|
||||||
|
Authorization: Bearer {{bob_relogin_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
# 15e — Unknown email → same uniform 200 (anti-enumeration). No
|
||||||
|
# mail is captured under that address.
|
||||||
|
POST {{base_url}}/api/auth/magic-link/send
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "email": "nobody-here@externalcompany.com" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.message" contains "sign-in link"
|
||||||
|
|
||||||
|
GET {{base_url}}/api/admin/smtp/test/captured?to=nobody-here@externalcompany.com
|
||||||
|
Authorization: Bearer {{alice_token}}
|
||||||
|
|
||||||
|
HTTP 404
|
||||||
|
|
||||||
|
# 15f — Email maps to an existing internal user with a password
|
||||||
|
# (Alice the admin) → uniform 200 but the magic link is NOT
|
||||||
|
# actually sent. has_login_credential() short-circuits the
|
||||||
|
# service so password/OIDC accounts cannot be bypassed via
|
||||||
|
# mailbox ownership at the moment of request.
|
||||||
|
POST {{base_url}}/api/auth/magic-link/send
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "email": "{{email}}" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.message" contains "sign-in link"
|
||||||
|
|
||||||
|
GET {{base_url}}/api/admin/smtp/test/captured?to={{email}}
|
||||||
|
Authorization: Bearer {{alice_token}}
|
||||||
|
|
||||||
|
HTTP 404
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 12 — Cleanup. Alice trashes the two test folders and
|
# Step 12 — Cleanup. Alice trashes the two test folders and
|
||||||
# deletes bob via the admin API so the suite's
|
# deletes bob via the admin API so the suite's
|
||||||
|
|||||||
Reference in New Issue
Block a user