Skip to content
Kumar Chandrachooda
Microservices

Identity in Fifteen Files

Pacco's Identity service fits its whole auth machinery - JWT issuance, a refresh-token aggregate, Redis deny-list revocation - in about fifteen files, and most of them are right. The three that aren't teach more - a mapper bug that leaves 202 callers polling forever, a dead password check, and a refresh token that never rotates.

By Kumar Chandrachooda 05 Jan 2026 6 min read
Fifteen small parts, three with hairline cracks

After part 8's posture audit it is only fair to give the mechanism its due, because Pacco.Services.Identity is the estate's best argument that authentication is not inherently big. Strip away the copied Convey glue that every service carries and the entire auth machinery — token issuance, refresh tokens, two kinds of revocation, the bus contract — fits in about fifteen files, most of them under forty lines: Program.cs and its seven endpoints; five command records (SignIn, SignUp, UseRefreshToken, RevokeAccessToken, RevokeRefreshToken); two application services (IdentityService, RefreshTokenService); three infrastructure adapters (JwtProvider, PasswordService, Rng); three domain types (User, RefreshToken, Role); and one exception-to-message mapper. This part walks the design that mostly works, then the three files where it doesn't — and the third of those is the best bug in the estate.

Seven endpoints and a bus subscription

Program.cs declares the whole HTTP surface with Convey's endpoint DSL — no controllers, no attributes, one builder expression: GET / (service name), GET /users/{userId} (admin-gated at the gateway), GET /me, POST /sign-in, POST /sign-up (201 pointing at identity/me), POST /access-tokens/revoke and POST /refresh-tokens/use and POST /refresh-tokens/revoke. One idiosyncrasy is worth pausing on: /me authenticates manually — a helper calls ctx.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme) inline and returns 401 on failure — because the service keeps its endpoints anonymous by default and opts in per route, rather than the middleware-guards-everything shape most templates start from.

And then, at the bottom of the infrastructure wiring, the line that matters for the bug later: SubscribeCommand<SignUp>(). Sign-up is dual-ingested — you can register over HTTP, or the async gateway can publish sign_up onto the identity exchange and the same handler runs. Two transports, one command, one code path; hold that thought.

Two token kinds, two revocation models

The refresh-token half is a genuinely tidy piece of domain modelling. RefreshToken is a small Mongo-persisted aggregate — user id, the token string, CreatedAt, a nullable RevokedAt with Revoked => RevokedAt.HasValue — whose constructor refuses blank tokens and whose one mutation, Revoke, refuses to fire twice. RefreshTokenService mints tokens with a thirty-character random string, and UseAsync reads like a checklist: unknown token throws, revoked token throws, missing user throws, otherwise mint a fresh JWT with the user's current role and permissions.

Access-token revocation takes the opposite trade. JWTs are stateless and unrevokable by design, so Convey's IAccessTokenService keeps a deny-list in RedisPOST /access-tokens/revoke writes the token there, and validation checks the list. That single decision is why an identity service has a Redis dependency at all, and the two models side by side make a nice seminar slide: refresh tokens are long-lived state (so revocation is a flag on an aggregate), access tokens are short-lived claims (so revocation is a deny-list with the same TTL as the token). Small service, both halves right.

Then there is the one right thing missing. Look at the end of UseAsync:

var auth = _jwtProvider.Create(token.UserId, user.Role, claims: claims);
auth.RefreshToken = refreshToken;

return auth;

The refresh token you spend is the refresh token you get back. There is no rotation: one string, minted at sign-in, remains valid for the account's lifetime unless explicitly revoked. Modern guidance (RFC 6749's successors and every major identity provider) rotates the refresh token on every use and treats reuse of a spent token as evidence of theft, revoking the whole family. Pacco has all the parts to do this — CreateAsync and Revoke already exist; rotation is three lines in UseAsync — which is exactly why the omission is worth teaching: it is not an architectural gap, it is a policy default nobody wrote down.

The dead check and the lying log

IdentityService.SignInAsync contains the estate's most quotable dead code:

var user = await _userRepository.GetAsync(command.Email);
if (user is null || !_passwordService.IsValid(user.Password, command.Password))
{
    _logger.LogError($"User with email: {command.Email} was not found.");
    throw new InvalidCredentialsException(command.Email);
}

if (!_passwordService.IsValid(user.Password, command.Password))
{
    _logger.LogError($"Invalid password for user with id: {user.Id.Value}");
    throw new InvalidCredentialsException(command.Email);
}

The first guard already covers the bad-password path, so the second if is unreachable — its condition can only be false by the time control arrives. Harmless, then, except for what it does to the logs: every wrong password is recorded as “user was not found”, because the arm that would have said “invalid password” is the dead one. The security behaviour is actually correct — both paths throw the same InvalidCredentialsException, which is precisely what you want (never tell a caller which half of the credential failed) — but an operator triaging a credential-stuffing incident from these logs would be reading fiction. The durable lesson: when you deliberately collapse two failure modes at the API, keep them distinct in telemetry — that is the one place the distinction still has value.

The mapper that rejects the wrong type

Now the headline. Pacco's convention pairs every command with a rejection event — fail sign_up and a sign_up_rejected should appear, which is what the Operations tracker (and therefore the 202-polling caller) relies on to move an operation to Rejected. The translation from exception to rejection event lives in one file, Infrastructure/Exceptions/ExceptionToMessageMapper.cs, and here it is, from the source, whole:

public object Map(Exception exception, object message)
    => exception switch
    {
        EmailInUseException ex => new SignUpRejected(ex.Email, ex.Message, ex.Code),
        InvalidCredentialsException ex => new SignInRejected(ex.Email, ex.Message, ex.Code),
        InvalidEmailException ex => message switch
        {
            SignIn command => new SignInRejected(command.Email, ex.Message, ex.Code),
            SignUpRejected command => new SignUpRejected(command.Email, ex.Message, ex.Code),
            _ => null
        },
        _ => null
    };

Read the inner switch slowly. InvalidEmailException can arise from two commands, so the mapper inspects the incoming message to pick the right rejection. The SignIn arm is correct. The second arm pattern-matches the message as SignUpRejected — the rejection event type — instead of SignUp. A rejected event is never an incoming message here; nothing that arrives will ever match that arm. The variable is even named command, which is the copy-paste slip fossilised: someone duplicated the SignIn line, reached for autocomplete, and landed one type too far down the list. It compiles for the worst possible reason — SignUpRejected happens to have an Email property too, so the arm is type-correct, just unreachable.

Trace the consequence through the estate. A bus-borne sign_up with a malformed email throws InvalidEmailException; the mapper returns null; no sign_up_rejected is published; Operations, which maps rejected events to the Rejected state, never hears. The operation the async gateway named in its 202 stays Pending — until the Redis entry's five-minute sliding expiry erases it, after which the caller's polling flips from “pending” to a 404 that means nothing. The HTTP path is untouched (the error mapper answers synchronous callers directly), so the bug lives only on the transport the demos don't exercise. It is the perfect specimen of the rejection-channel decay that rejected events and how they rot catalogues across the domain services — and the reason contract conventions (“every command has a rejection twin”) need a test, not a habit. A one-line unit test per exception type — given this exception and this command, a rejection is produced — would have caught a bug that no amount of running the happy path ever will.

The ledger, weighed

Small honest extras, for completeness: Core/Exceptions/InvalidRoleException.cs quietly contains a second class, EmptyRefreshTokenException, so the file listing under-reports the type inventory by one; and the outbox that publishes SignedUp runs with transactions disabled like everywhere else in the estate. Against all that, the credit column is real: a clean aggregate, both revocation models, dual-ingestion done with one handler, and an auth surface a new reader can hold in their head in an afternoon. Fifteen files, three findings — a missing policy, a dead branch, and a wrong type pattern — and each finding is small precisely because the service is small. That is the part I would actually copy: not the code, but the size. Bugs in a fifteen-file service are fifteen-file bugs; you can quote them whole in a blog post. Try that with an identity platform.

Next, the view from above all thirteen repos at once: the solution that aggregates them over sibling paths, the clone scripts that assemble it, the wildcard that stands in for a version, and the package id with a trailing underscore — thirteen repos and a floating version.