//! `/api/auth/login` and `/api/auth/logout` (rulings 17 and 18).
//!
//! Login is the one route that is always reachable without a session, and the
//! one that must never help a guess along: a wrong password and an unknown one
//! are the same 401, and the only thing this file logs is the client address
//! and the outcome. No password, no hash, no token and no cookie value is ever
//! formatted anywhere here (ruling 29).
//!
//! A hash this build cannot read is a 500, not a 401. Answering 401 would tell
//! an operator with a corrupted `web.password_hash` that their password is
//! wrong, and they would go on retyping a password that can never verify.
//!
//! With no password set, authentication is off and every route is already open,
//! so a login attempt succeeds without minting anything: the answer says
//! `auth_required: false` and carries no cookie, because a session that
//! authorises nothing would be a lie the browser stores.

const std = @import("std");

const auth = @import("../auth.zig");
const http_util = @import("../http_util.zig");
const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig");
const server = @import("../server.zig");

const Failure = mutations.Failure;
const Request = http_util.Request;
const HandlerError = http_util.HandlerError;

const log = std.log.scoped(.web_auth);

const LoginBody = struct {
    password: []const u8,
};

/// Long enough for the cookie plus its attributes and a `Max-Age`.
const cookie_buf_len = 192;

pub const Login = union(enum) {
    /// A session was minted; the value is the cookie to set.
    cookie: auth.Cookie,
    /// No password is configured, so there is nothing to log in to.
    no_auth,
    fail: Failure,
};

/// Verifies and, on success, mints a session (ruling 17).
pub fn applyLogin(state: *server.WebState, io: std.Io, password: []const u8) Login {
    // The live hash, never `state.web.password_hash`: a settings PUT may have
    // replaced the password since boot, and the revoked one must stop minting
    // sessions immediately. An unreadable stored hash is a 500, not a 401,
    // for the same reason a broken PHC string is.
    var hash_buf: [auth.LiveHash.max_len]u8 = undefined;
    const snapshot = state.live_hash.copy(io, &hash_buf) catch
        return .{ .fail = .{ .internal = error.Unexpected } };
    if (snapshot.hash.len == 0) return .no_auth;

    const sessions = state.sessions orelse
        return .{ .fail = .{ .unavailable = "no session store" } };

    const outcome = auth.verifyPassword(io, state.gpa, snapshot.hash, password) catch
        return .{ .fail = .{ .unavailable = "shutting down" } };

    switch (outcome) {
        .denied => return .{ .fail = .{ .invalid = "invalid password" } },
        .unavailable => return .{ .fail = .{ .internal = error.Unexpected } },
        .ok => {},
    }

    return confirmLogin(state, io, sessions, snapshot.generation);
}

/// The step after a successful verification, separated so a test can install
/// a replacement hash between verify and confirm. Verification ran against a
/// copy, outside any lock: a settings PUT may have installed a new hash and
/// cleared every session in the meantime, and minting for the old hash then
/// would hand the revoked password a live session. `confirmSession` answers
/// null exactly in that case, and the login is denied the same way a wrong
/// password is — the operator retries with the password that now applies.
fn confirmLogin(
    state: *server.WebState,
    io: std.Io,
    sessions: *auth.Sessions,
    generation: u64,
) Login {
    const cookie = state.live_hash.confirmSession(io, sessions, generation) catch
        return .{ .fail = .{ .internal = error.Unexpected } };
    if (cookie) |value| return .{ .cookie = value };
    return .{ .fail = .{ .invalid = "invalid password" } };
}

/// Ends the session the cookie names. An unknown cookie is not an error: the
/// point of logging out is to end up logged out, which is where it already is.
fn applyLogout(state: *server.WebState, io: std.Io, cookie_header: []const u8) bool {
    const sessions = state.sessions orelse return false;
    const value = http_util.cookieValue(cookie_header, auth.cookie_name) orelse return false;
    return sessions.logout(io, value);
}

// ---------------------------------------------------------------------------
// routes
// ---------------------------------------------------------------------------

pub fn login(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
    const parsed = http_util.parseBody(LoginBody, request) catch |err|
        return mutations.respondBadBody(request, err);

    switch (applyLogin(state, io, parsed.value.password)) {
        .no_auth => {
            return http_util.respondJson(request, .ok, .{
                .authenticated = true,
                .auth_required = false,
            }, &.{});
        },
        .fail => |failure| {
            // Ruling 18: a refused login is a 401, not the 400 an invalid value
            // would earn elsewhere. Only the address and the outcome are logged.
            if (failure == .invalid) {
                log.warn("web login refused for {f}", .{request.client_addr});
                return http_util.respondError(request, .unauthorized, "invalid password");
            }
            return mutations.respondFailure(request, failure, "verifying the web password");
        },
        .cookie => |cookie| {
            log.info("web login accepted for {f}", .{request.client_addr});
            var buf: [cookie_buf_len]u8 = undefined;
            // The live table's TTL, not `state.web`'s boot snapshot: a
            // settings apply changes the TTL in place.
            const ttl_s = if (state.sessions) |sessions|
                sessions.ttlSeconds(io)
            else
                model.sessionTtlSeconds(state.web);
            const header = http_util.formatSetCookie(
                &buf,
                auth.cookie_name,
                &cookie,
                ttl_s,
            ) catch return error.OutOfMemory;
            return http_util.respondJson(request, .ok, .{
                .authenticated = true,
                .auth_required = true,
            }, &.{.{ .name = "set-cookie", .value = header }});
        },
    }
}

pub fn logout(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
    _ = applyLogout(state, io, request.cookie);

    var buf: [cookie_buf_len]u8 = undefined;
    const header = http_util.formatSetCookie(&buf, auth.cookie_name, "", 0) catch
        return error.OutOfMemory;

    return http_util.respondJson(
        request,
        .ok,
        .{ .authenticated = false },
        &.{.{ .name = "set-cookie", .value = header }},
    );
}

// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------

const testing = std.testing;

const TestIo = struct {
    threaded: std.Io.Threaded,

    fn init() TestIo {
        return .{ .threaded = .init(testing.allocator, .{}) };
    }

    fn io(self: *TestIo) std.Io {
        return self.threaded.io();
    }

    fn deinit(self: *TestIo) void {
        self.threaded.deinit();
    }
};

/// Hashes `password` the way `PUT /api/settings` does, so the login tests
/// verify against a hash this build actually produced.
fn hashOf(io: std.Io, buf: []u8, password: []const u8) ![]const u8 {
    return std.crypto.pwhash.argon2.strHash(password, .{
        .allocator = testing.allocator,
        .params = .owasp_2id,
        .mode = .argon2id,
        .encoding = .phc,
    }, buf, io);
}

test "with no password set, a login succeeds without minting a session" {
    var t: TestIo = .init();
    defer t.deinit();

    var sessions: auth.Sessions = .init(24);
    var state: server.WebState = .{ .gpa = testing.allocator, .sessions = &sessions };

    try testing.expectEqual(Login.no_auth, applyLogin(&state, t.io(), "anything"));
    try testing.expectEqual(@as(u32, 0), sessions.count(t.io(), 0));
}

test "the right password mints a session the cookie then validates" {
    var t: TestIo = .init();
    defer t.deinit();

    var buf: [256]u8 = undefined;
    const hash = try hashOf(t.io(), &buf, "hunter2");

    var sessions: auth.Sessions = .init(24);
    var state: server.WebState = .{
        .gpa = testing.allocator,
        .live_hash = .init(hash),
        .sessions = &sessions,
    };

    const outcome = applyLogin(&state, t.io(), "hunter2");
    try testing.expect(sessions.validate(t.io(), &outcome.cookie));
}

test "the wrong password is refused and mints nothing" {
    var t: TestIo = .init();
    defer t.deinit();

    var buf: [256]u8 = undefined;
    const hash = try hashOf(t.io(), &buf, "hunter2");

    var sessions: auth.Sessions = .init(24);
    var state: server.WebState = .{
        .gpa = testing.allocator,
        .live_hash = .init(hash),
        .sessions = &sessions,
    };

    const outcome = applyLogin(&state, t.io(), "hunter3");
    try testing.expect(outcome.fail == .invalid);
    try testing.expectEqual(@as(u32, 0), sessions.count(t.io(), 0));

    // An empty password is refused without reaching argon2 at all.
    try testing.expect(applyLogin(&state, t.io(), "").fail == .invalid);
}

test "a password change between verify and confirm denies the login" {
    var t: TestIo = .init();
    defer t.deinit();
    const gpa = testing.allocator;

    var buf: [256]u8 = undefined;
    const hash = try hashOf(t.io(), &buf, "hunter2");

    var sessions: auth.Sessions = .init(24);
    var state: server.WebState = .{
        .gpa = gpa,
        .live_hash = .init(hash),
        .sessions = &sessions,
    };
    defer state.live_hash.deinit(gpa);

    // The login path up to and including verification, as applyLogin runs it.
    var hash_buf: [auth.LiveHash.max_len]u8 = undefined;
    const snapshot = try state.live_hash.copy(t.io(), &hash_buf);
    try testing.expectEqual(
        auth.Outcome.ok,
        try auth.verifyPassword(t.io(), gpa, snapshot.hash, "hunter2"),
    );

    // A settings PUT lands while argon2 was grinding: new hash in, every
    // session out, one operation.
    state.live_hash.installAndRevoke(
        t.io(),
        gpa,
        &sessions,
        try gpa.dupe(u8, "$argon2id$v=19$m=19456,t=2,p=1$a$b"),
    );

    // The confirm step must not mint from the revoked password.
    const outcome = confirmLogin(&state, t.io(), &sessions, snapshot.generation);
    try testing.expect(outcome.fail == .invalid);
    try testing.expectEqual(@as(u32, 0), sessions.count(t.io(), 0));
}

test "a login confirmed before the password transition does not survive it" {
    var t: TestIo = .init();
    defer t.deinit();
    const gpa = testing.allocator;

    var buf: [256]u8 = undefined;
    const hash = try hashOf(t.io(), &buf, "hunter2");

    var sessions: auth.Sessions = .init(24);
    var state: server.WebState = .{
        .gpa = gpa,
        .live_hash = .init(hash),
        .sessions = &sessions,
    };
    defer state.live_hash.deinit(gpa);

    var hash_buf: [auth.LiveHash.max_len]u8 = undefined;
    const snapshot = try state.live_hash.copy(t.io(), &hash_buf);
    const outcome = confirmLogin(&state, t.io(), &sessions, snapshot.generation);
    try testing.expect(sessions.validate(t.io(), &outcome.cookie));

    // The settings PUT lands after the confirm: the revocation nested in the
    // transition ends the session it just minted.
    state.live_hash.installAndRevoke(
        t.io(),
        gpa,
        &sessions,
        try gpa.dupe(u8, "$argon2id$v=19$m=19456,t=2,p=1$a$b"),
    );
    try testing.expect(!sessions.validate(t.io(), &outcome.cookie));
    try testing.expectEqual(@as(u32, 0), sessions.count(t.io(), 0));
}

test "confirmLogin mints when no install intervened" {
    var t: TestIo = .init();
    defer t.deinit();

    var buf: [256]u8 = undefined;
    const hash = try hashOf(t.io(), &buf, "hunter2");

    var sessions: auth.Sessions = .init(24);
    var state: server.WebState = .{
        .gpa = testing.allocator,
        .live_hash = .init(hash),
        .sessions = &sessions,
    };

    var hash_buf: [auth.LiveHash.max_len]u8 = undefined;
    const snapshot = try state.live_hash.copy(t.io(), &hash_buf);
    const outcome = confirmLogin(&state, t.io(), &sessions, snapshot.generation);
    try testing.expect(sessions.validate(t.io(), &outcome.cookie));
}

test "a hash this build cannot read is a 500, not a refusal" {
    var t: TestIo = .init();
    defer t.deinit();

    var sessions: auth.Sessions = .init(24);
    var state: server.WebState = .{
        .gpa = testing.allocator,
        .live_hash = .init("$argon2id$not a phc string"),
        .sessions = &sessions,
    };

    const outcome = applyLogin(&state, t.io(), "hunter2");
    try testing.expectEqual(auth.Outcome.unavailable, try auth.verifyPassword(
        t.io(),
        testing.allocator,
        "$argon2id$not a phc string",
        "hunter2",
    ));
    try testing.expect(outcome.fail == .internal);
}

test "a password set with no session store refuses rather than opens" {
    var t: TestIo = .init();
    defer t.deinit();

    var state: server.WebState = .{
        .gpa = testing.allocator,
        .live_hash = .init("$argon2id$v=19$m=19456,t=2,p=1$a$b"),
    };

    try testing.expect(applyLogin(&state, t.io(), "hunter2").fail == .unavailable);
}

test "logging out ends the session the cookie names" {
    var t: TestIo = .init();
    defer t.deinit();

    var sessions: auth.Sessions = .init(24);
    var state: server.WebState = .{ .gpa = testing.allocator, .sessions = &sessions };

    const cookie = sessions.createWithToken(t.io(), @splat(3), 1_000);
    var header_buf: [128]u8 = undefined;
    const header = try std.fmt.bufPrint(&header_buf, "{s}={s}", .{ auth.cookie_name, &cookie });

    try testing.expect(applyLogout(&state, t.io(), header));
    try testing.expect(!sessions.validateAt(t.io(), &cookie, 1_001));

    // Logging out twice, or with no cookie at all, is not an error.
    try testing.expect(!applyLogout(&state, t.io(), header));
    try testing.expect(!applyLogout(&state, t.io(), ""));
}
