Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ e2e-playground: ## Run the live-playground e2e suite (OIDC/SAML/SCIM/SSO/OAuth/M
docker compose -f e2e-playground/docker-compose.yml build; \
status=$$?; \
if [ $$status -eq 0 ]; then \
docker compose -f e2e-playground/docker-compose.yml up -d --wait authorizer authorizer-sso mock-oauth mock-saml-idp mailpit sms-sink; \
docker compose -f e2e-playground/docker-compose.yml up -d --wait authorizer authorizer-sso authorizer-email-verify mock-oauth mock-saml-idp mailpit sms-sink; \
status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
Expand Down
22 changes: 22 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ func init() {
f.StringVar(&rootArgs.config.MicrosoftClientSecret, "microsoft-client-secret", "", "Client secret for Microsoft")
f.StringVar(&rootArgs.config.MicrosoftTenantID, "microsoft-tenant-id", defaultMicrosoftTenantID, "Tenant ID for Microsoft")
f.StringSliceVar(&rootArgs.config.MicrosoftScopes, "microsoft-scopes", defaultMicrosoftScopes, "Scopes for Microsoft")
f.StringSliceVar(&rootArgs.config.MicrosoftAllowedTenants, "microsoft-allowed-tenants", nil, "Entra tenant IDs allowed to sign in when --microsoft-tenant-id is a multi-tenant alias (common/organizations/consumers). Empty allows any tenant, but an untrusted tenant's email will not link to an existing account")
f.BoolVar(&rootArgs.config.OAuthAllowUnverifiedProviderEmail, "oauth-allow-unverified-provider-email", false, "Compatibility escape hatch: let a social login whose provider did not attest the email address sign up or return to an account that same provider already owns. It still cannot cross into an account another credential owns. Prefer pinning --microsoft-tenant-id or enabling the xms_edov claim; see docs/email-verification-contract.md")
f.StringVar(&rootArgs.config.TwitchClientID, "twitch-client-id", "", "Client ID for Twitch")
f.StringVar(&rootArgs.config.TwitchClientSecret, "twitch-client-secret", "", "Client secret for Twitch")
f.StringSliceVar(&rootArgs.config.TwitchScopes, "twitch-scopes", defaultTwitchScopes, "Scopes for Twitch")
Expand Down Expand Up @@ -455,6 +457,26 @@ func runRoot(c *cobra.Command, args []string) {
log.Warn().Msg("--encryption-key is not set and has fallen back to --jwt-secret. Secrets at rest (TOTP seeds, OTP digests) are keyed by the same value that signs tokens, so rotating --jwt-secret will lock out every enrolled TOTP user — there is no re-encryption path. Set a distinct --encryption-key now; doing it after users enrol requires them to re-enrol.")
}

// Email verification with no way to send email is an unrecoverable trap, not
// a degraded mode: signup creates the account unverified, the verification
// mail never leaves, and every self-service route out of that state (the
// signup link, resend-verification, the login email-OTP fallback) is the
// same mailbox. The user is stranded permanently, and an unverified account
// also blocks a federated login for the same address. Fail at boot, where
// the operator can see it, rather than silently per-user.
if rootArgs.config.EnableEmailVerification && !rootArgs.config.IsEmailServiceEnabled {
log.Fatal().Msg("--enable-email-verification=true requires a working email service, but SMTP is not configured. Users would be created unverified with no way to ever verify. Set --smtp-host, --smtp-port and --smtp-sender-email, or disable email verification.")
}

// The compatibility escape hatch for unattested federated emails. It is
// narrowed (an unattested address still cannot cross into an account another
// credential owns), but it leaves same-provider collisions open — two Entra
// tenants asserting one address. Warn every boot so it does not quietly
// become permanent.
if rootArgs.config.OAuthAllowUnverifiedProviderEmail {
log.Warn().Msg("--oauth-allow-unverified-provider-email is set: a social login whose provider does not attest the email address may still sign up or return to an account that same provider owns. Cross-credential takeover is still blocked, but two principals of the SAME provider (e.g. two Entra tenants) can collide on one address. Pin --microsoft-tenant-id, set --microsoft-allowed-tenants, or enable the xms_edov optional claim, then remove this flag. See docs/email-verification-contract.md.")
}

// Initialize prometheus metrics
metrics.Init()

Expand Down
327 changes: 327 additions & 0 deletions docs/email-verification-contract.md

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions e2e-playground/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,54 @@ services:
timeout: 2s
retries: 30

# authorizer-email-verify is the only instance combining basic-auth signup with
# --enable-email-verification=true. It exists because those two cannot coexist
# anywhere else: the shared `authorizer` service has verification OFF (dozens of
# specs rely on signup returning a session immediately), and both magic-link
# instances have it ON but also --enable-magic-link-login, which makes
# AuthorizerSignup hide the password form entirely (web/app renders no signup
# inputs at all). Without this service the rendered "signup -> check your inbox
# -> click link" journey — the user-facing half of the email-verification
# contract, see docs/email-verification-contract.md — is untestable through the UI.
authorizer-email-verify:
build:
context: ..
dockerfile: Dockerfile
ports:
- "8086:8080"
command:
- "--http-port=8080"
- "--url=http://authorizer-email-verify:8080"
- "--database-type=sqlite"
- "--database-url=/authorizer/e2e-test-email-verify.db"
- "--admin-secret=e2e-admin-secret"
- "--jwt-type=HS256"
- "--jwt-secret=e2e-jwt-secret-do-not-use-in-prod"
- "--client-id=e2e-client-id"
- "--client-secret=e2e-client-secret"
- "--enable-signup=true"
- "--enable-email-verification=true"
# Keeps the rendered signup form on the password path (magic link would
# replace it) and keeps a fresh signup off the MFA-offer screen, so the
# "check your inbox" assertion is not racing an interstitial.
- "--disable-mfa=true"
- "--app-cookie-secure=false"
- "--admin-cookie-secure=false"
- "--app-cookie-same-site=lax"
- "--allowed-origins=http://localhost:8086,http://authorizer-email-verify:8080"
- "--smtp-host=mailpit"
- "--smtp-port=1025"
- "--smtp-sender-email=e2e@authorizer.test"
- "--rate-limit-rps=1000"
- "--rate-limit-burst=1000"
depends_on:
mailpit: { condition: service_started }
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
interval: 2s
timeout: 2s
retries: 30

# authorizer-mfa-enforced is a fifth instance solely for
# tests/mfa-routing-matrix.spec.ts (the `mfa-on` project). EnforceMFA
# turned out NOT to be a runtime-toggleable admin setting - Task 28
Expand Down Expand Up @@ -530,6 +578,7 @@ services:
AUTHORIZER_SSO_BASE_URL: http://authorizer-sso:8080
AUTHORIZER_WEBAUTHN_BASE_URL: http://webauthn.e2e-playground.test:8080
AUTHORIZER_MAGIC_LINK_BASE_URL: http://authorizer-magic-link:8080
AUTHORIZER_EMAIL_VERIFY_BASE_URL: http://authorizer-email-verify:8080
AUTHORIZER_MFA_ENFORCED_BASE_URL: http://authorizer-mfa-enforced:8080
AUTHORIZER_MFA_MAGIC_LINK_BASE_URL: http://authorizer-mfa-magic-link:8080
# Two replicas of ONE deployment over a shared Postgres — see the
Expand All @@ -553,6 +602,7 @@ services:
authorizer-sso: { condition: service_healthy }
authorizer-webauthn: { condition: service_healthy }
authorizer-magic-link: { condition: service_healthy }
authorizer-email-verify: { condition: service_healthy }
authorizer-mfa-enforced: { condition: service_healthy }
authorizer-mfa-magic-link: { condition: service_healthy }
authorizer-replica-a: { condition: service_healthy }
Expand Down
6 changes: 3 additions & 3 deletions e2e-playground/fixtures/adminClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,15 +153,15 @@ export async function getUserIdByEmail(email: string): Promise<string> {
// just that a session was established.
export async function getUserByEmail(
email: string
): Promise<{ id: string; email: string | null; given_name: string | null; family_name: string | null; signup_methods: string }> {
): Promise<{ id: string; email: string | null; given_name: string | null; family_name: string | null; signup_methods: string; email_verified: boolean }> {
const query = gql`
query ($params: ListUsersRequest) {
_users(params: $params) { users { id email given_name family_name signup_methods } }
_users(params: $params) { users { id email given_name family_name signup_methods email_verified } }
}
`;
const res = await client.request<{
_users: {
users: { id: string; email: string | null; given_name: string | null; family_name: string | null; signup_methods: string }[];
users: { id: string; email: string | null; given_name: string | null; family_name: string | null; signup_methods: string; email_verified: boolean }[];
};
}>(query, { params: { query: email } });
const user = res._users.users.find((u) => u.email === email);
Expand Down
10 changes: 7 additions & 3 deletions e2e-playground/mocks/mock-oauth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,17 @@ function defaultProfile(provider: string): Record<string, unknown> {
// (processDiscordUserInfo, internal/http_handlers/oauth_callback.go,
// reads id/username/avatar/email directly - no "user" wrapper, that
// was /oauth2/@me's shape, which never includes email).
return { id: '123', username: 'mockuser', avatar: 'abc', email };
// `verified` is Discord's email-confirmation flag; Authorizer refuses to
// resolve a local account from an address the provider hasn't attested.
return { id: '123', username: 'mockuser', avatar: 'abc', email, verified: true };
case 'twitter':
return { data: { id: '123', name: 'Mock User', username: 'mockuser', profile_image_url: 'https://example.com/a.png' } };
case 'roblox':
return { name: 'Mock User', nickname: 'mockuser', picture: 'https://example.com/a.png', email };
return { name: 'Mock User', nickname: 'mockuser', picture: 'https://example.com/a.png', email, email_verified: true };
default:
return { sub: `mock-${provider}-sub`, email, given_name: 'Mock', family_name: 'User' };
// OIDC `email_verified` (Core §5.1). Google/Apple/Twitch/Microsoft all
// route through here, and the callback rejects an unattested address.
return { sub: `mock-${provider}-sub`, email, email_verified: true, given_name: 'Mock', family_name: 'User' };
}
}

Expand Down
19 changes: 18 additions & 1 deletion e2e-playground/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export default defineConfig({
/sso-discovery\.spec\.ts/,
/webauthn\.spec\.ts/,
/magic-link\.spec\.ts/,
/email-verification-database\.spec\.ts/,
/email-verification-ui\.spec\.ts/,
// Drives authorizer-replica-a/-b directly by absolute URL rather than
// this project's baseURL; see the `replica` project below.
/replica-shared-state\.spec\.ts/,
Expand Down Expand Up @@ -79,12 +81,27 @@ export default defineConfig({
// docker-compose.yml for why those can't live on the shared
// `authorizer` service.
name: 'magic-link',
testMatch: /magic-link\.spec\.ts/,
// email-verification-database.spec.ts rides along here because this is
// the only instance with --enable-email-verification=true, which is what
// makes the pre-click "email_verified: false" state observable at all.
testMatch: [/magic-link\.spec\.ts/, /email-verification-database\.spec\.ts/],
use: {
...devices['Desktop Chrome'],
baseURL: process.env.AUTHORIZER_MAGIC_LINK_BASE_URL || 'http://localhost:8083',
},
},
{
// Runs against authorizer-email-verify (docker-compose.yml) — the only
// instance combining basic-auth signup with --enable-email-verification,
// which is what makes the rendered "signup -> check your inbox -> click
// link" journey reachable at all. See that service's comment.
name: 'email-verify',
testMatch: /email-verification-ui\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
baseURL: process.env.AUTHORIZER_EMAIL_VERIFY_BASE_URL || 'http://localhost:8086',
},
},
{
// Runs against authorizer-replica-a AND authorizer-replica-b — two
// replicas of ONE deployment sharing a Postgres (docker-compose.yml).
Expand Down
12 changes: 7 additions & 5 deletions e2e-playground/sdk-tests/python/tests/test_social_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def _case(provider: str) -> Case:
email = f"{provider}-{uid}@example.com"
p: dict[str, Any]
if provider == "google":
p = {"sub": f"google-{uid}", "email": email, "given_name": "Ada", "family_name": "Lovelace"}
p = {"sub": f"google-{uid}", "email": email, "email_verified": True, "given_name": "Ada", "family_name": "Lovelace"}
return Case(provider, p, "email", email, "Ada", "Lovelace", email)
if provider == "github":
p = {"name": "Grace Hopper", "email": email, "avatar_url": "https://example.com/a.png"}
Expand All @@ -79,31 +79,33 @@ def _case(provider: str) -> Case:
}
return Case(provider, p, "email", email, "Katherine", "Johnson", email)
if provider == "linkedin":
p = {"localizedFirstName": "Margaret", "localizedLastName": "Hamilton", "email": email}
p = {"localizedFirstName": "Margaret", "localizedLastName": "Hamilton", "email": email, "email_verified": True}
return Case(provider, p, "email", email, "Margaret", "Hamilton", email)
if provider == "apple":
p = {"sub": f"apple-{uid}", "email": email, "given_name": "Alan", "family_name": "Turing"}
p = {"sub": f"apple-{uid}", "email": email, "email_verified": True, "given_name": "Alan", "family_name": "Turing"}
return Case(provider, p, "email", email, "Alan", "Turing", email)
if provider == "discord":
p = {"id": f"discord-{uid}", "username": "gracehopper", "avatar": "abc", "email": email}
p = {"id": f"discord-{uid}", "username": "gracehopper", "avatar": "abc", "email": email, "verified": True}
return Case(provider, p, "email", email, "gracehopper", None, email)
if provider == "microsoft":
p = {
"sub": f"microsoft-{uid}",
"email": email,
"email_verified": True,
"given_name": "Katherine",
"family_name": "Johnson",
}
return Case(provider, p, "email", email, "Katherine", "Johnson", email)
if provider == "twitch":
p = {"sub": f"twitch-{uid}", "email": email, "given_name": "Sally", "family_name": "Ride"}
p = {"sub": f"twitch-{uid}", "email": email, "email_verified": True, "given_name": "Sally", "family_name": "Ride"}
return Case(provider, p, "email", email, "Sally", "Ride", email)
if provider == "roblox":
p = {
"name": "Ada Lovelace",
"nickname": "ada",
"picture": "https://example.com/a.png",
"email": email,
"email_verified": True,
}
return Case(provider, p, "email", email, "Ada ", "Lovelace", email)
if provider == "twitter":
Expand Down
2 changes: 1 addition & 1 deletion e2e-playground/tests/oidc-sso-rp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ test.describe('OIDC — SSO relying party (home-realm discovery)', () => {

const employeeEmail = `employee@${domain}`;
await request.post(`${MOCK_OAUTH_BASE}/${realm}/__configure`, {
data: { profile: { sub: 'employee-1', email: employeeEmail, given_name: 'Ada', family_name: 'Lovelace' } },
data: { profile: { sub: 'employee-1', email: employeeEmail, email_verified: true, given_name: 'Ada', family_name: 'Lovelace' } },
});

await page.goto('/app');
Expand Down
4 changes: 2 additions & 2 deletions e2e-playground/tests/social/apple.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ test.describe('Social login — Apple', () => {
// Authorizer's ctx.Request.FormValue("user") resolves identically to a
// POST body. Email still comes off the id_token, matching Apple's real
// private relay pattern.
profile: { sub: `apple-${crypto.randomUUID()}`, email, given_name: 'Alan', family_name: 'Turing' },
profile: { sub: `apple-${crypto.randomUUID()}`, email, email_verified: true, given_name: 'Alan', family_name: 'Turing' },
expectedEmail: email,
});

Expand Down Expand Up @@ -66,7 +66,7 @@ test.describe('Social login — Apple', () => {
await runSocialLoginHappyPath(page, request, {
provider: 'apple',
buttonName: /apple/i,
profile: { sub: `apple-${crypto.randomUUID()}`, email, given_name: 'Grace', family_name: 'Hopper' },
profile: { sub: `apple-${crypto.randomUUID()}`, email, email_verified: true, given_name: 'Grace', family_name: 'Hopper' },
expectedEmail: email,
});

Expand Down
2 changes: 1 addition & 1 deletion e2e-playground/tests/social/discord.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ test.describe('Social login — Discord', () => {
await runSocialLoginHappyPath(page, request, {
provider: 'discord',
buttonName: /discord/i,
profile: { id: discordId, username: 'gracehopper', avatar: 'abc123', email },
profile: { id: discordId, username: 'gracehopper', avatar: 'abc123', email, verified: true },
expectedEmail: email,
});

Expand Down
2 changes: 1 addition & 1 deletion e2e-playground/tests/social/google.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ test.describe('Social login — Google', () => {
// profile is signed into a real id_token (server.ts), and
// processGoogleUserInfo (internal/http_handlers/oauth_callback.go)
// reads given_name/family_name/email/sub straight off its claims.
profile: { sub: `google-${crypto.randomUUID()}`, email, given_name: 'Ada', family_name: 'Lovelace' },
profile: { sub: `google-${crypto.randomUUID()}`, email, email_verified: true, given_name: 'Ada', family_name: 'Lovelace' },
expectedEmail: email,
});

Expand Down
48 changes: 48 additions & 0 deletions e2e-playground/tests/social/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,51 @@ export async function runConsentDeniedNegativePath(
const replayBody = await replayRes.json();
expect(replayBody.error).toBe('invalid oauth state');
}

// runSocialLoginExpectingRejection drives the same real redirect chain as
// runSocialLoginHappyPath, but over the API request context so the callback's
// status code and error body are observable (a browser only ever sees the
// rendered result). Returns the parsed callback response.
//
// Used by the email-verification-contract spec: when an IdP hands back an
// address it has not attested, the callback must refuse BEFORE any local
// account is looked up or created, so there is no session and no account
// mutation to assert away afterwards.
export async function runSocialLoginExpectingRejection(
request: APIRequestContext,
baseURL: string,
opts: { provider: string; profile: Record<string, unknown> }
): Promise<{ status: number; body: { error?: string; error_description?: string } }> {
await configureProviderProfile(request, opts.provider, opts.profile);

// 1. Real login initiation — the same route the rendered social button hits.
const redirectUri = `${baseURL}/app`;
const loginRes = await request.get(
`/oauth_login/${opts.provider}?redirect_uri=${encodeURIComponent(redirectUri)}`,
{ maxRedirects: 0 }
);
expect(loginRes.status()).toBe(307);
const authorizeLocation = loginRes.headers()['location'];
expect(authorizeLocation).toBeTruthy();

// 2. Mock provider's /authorize issues a real code and bounces to our callback.
const authorizeRes = await request.get(authorizeLocation!, { maxRedirects: 0 });
expect(authorizeRes.status()).toBe(302);
const callbackLocation = authorizeRes.headers()['location'];
expect(callbackLocation).toBeTruthy();

// 3. The callback: full token exchange + id_token verification happens here,
// then the email-attestation gate.
const callbackURL = new URL(callbackLocation!);
const callbackRes = await request.get(`${callbackURL.pathname}${callbackURL.search}`, {
maxRedirects: 0,
});
let body: { error?: string; error_description?: string } = {};
try {
body = await callbackRes.json();
} catch {
// Non-JSON (a redirect body) means the login was NOT rejected; the caller's
// assertion on status/error will report that.
}
return { status: callbackRes.status(), body };
}
Loading
Loading