In the last few episodes of this devlog saga, we laid down the basics. From crafting our pkg
utilities to sketching out the core architecture.
Now it's time to work on one of the most important parts of any app: Authentication.
Yes, that magical system that decides whether you're a trusted user or just some stranger.
And, get ready for another temporary hardcoded user. Promise, it's just for testing. (I know, we said the same thing about any other config in previous part :D).
In this post, we'll walk through the authentication domain, use cases, and how we wire them together using dependency injection.
While the implementation uses a hardcoded user for now (since the database setup comes later), all the token mechanics are already production-style.
The Authentication Domain
At its core, the authentication domain has just two main players: Credential and Session.
You give us your credentials, we give you a session. Simple as that. At least in theory.
The authentication domain is basically the brain of our login system.
It's where we keep all the logic for:
- Validating login credentials (right now for our VIP hardcoded user membership).
- Generating JWT tokens.
- Handling refresh logic.
- Revoking tokens so they can't be reused.
The cool part? It doesn't care about HTTP, JSON, or any of that transport stuff. It's just pure business logic.
And, before your forgot that this is a Cursor-powered application, let's kick things off the right way, by stating the Cursor Rule:
### Credential
Represents the input required for user authentication (login).
- **Fields:**
- Email: type `user.Email` (referenced from user package), validated as `required,email`
- Password: type `user.Password` (referenced from user package), validated as `required,password`
- **Best Practices:**
- Do not store credentials; use only for authentication input
- Always validate both fields before processing
- **Method:**
- `Verify(ctx context.Context, matchedUser *user.User) error`: Checks if the provided password matches the user's stored ciphertext. Returns nil if matched, otherwise returns InvalidCredential. Uses the Password's validation and matching logic.
## Session Object
Represents the output of a successful authentication (login), containing issued tokens.
- **Fields:**
- AccessToken: type `token.Token` (referenced from token package)
- RefreshToken: type `token.Token` (referenced from token package)
- **Best Practices:**
- AccessToken is used for short-lived API access
- RefreshToken is used to obtain new access tokens
- Consider implementing refresh token rotation for enhanced security
- Do not expose sensitive token details in logs or user-facing messages
## Token Subject Constants
- **AccessTokenSubject**: Constant string value representing the subject for access tokens (e.g., "access_token").
- **RefreshTokenSubject**: Constant string value representing the subject for refresh tokens (e.g., "refresh_token").
- **Usage**: These constants should be used as the `Subject` field in token claims to distinguish between access and refresh tokens throughout the authentication flow.
We generate two tokens: an access_token and a refresh_token, and store their metadata in Redis.
This metadata is the Session Object, and it's what lets us fully control a token's lifecycle.
Field | Description |
---|---|
access_token |
A short-lived token (valid for 2 hours) used for most API requests. |
refresh_token |
A longer-lived token (valid for 48 hours), inactive for the first 2 hours. |
These tokens are not just random strings. They are stored in Redis along with metadata records in redis.
Field | Description |
---|---|
exp |
Expiration timestamp. |
nbf |
"Not before” timestamp, ensuring the refresh token can't be used too early. |
jti |
Unique token ID, similar to a JWT claim. |
user_id |
The ID of the authenticated user. |
link_id |
A link between an access token and its refresh token (used for revocation). |
Storage keys in Redis:
-
access:<jti>
→ metadata -
refresh:<jti>
→ metadata
Why this matters:
The Session Object is the source of truth for authentication. It allows us to:
- Enforce expiration and "not before" rules.
- Implement revocation instantly (kill a token in Redis, and it's dead on the next request).
- Revoke both tokens at once via link_id.
- Avoid trusting client-side tokens blindly. Every token is validated against Redis.
Use Cases
We (I and cursor) have implemented three core authentication flows.
1. Login Endpoint
The login flow takes in email and password (right now, it's checked against a hardcoded user. Don't judge, it's just a demo).
If you pass the vibe check, you get:
- An access token (valid for 2 hours)
- A refresh token (valid for 48 hours, but time-locked for the first 2 hours via nbf).
Why the lock? Because we don't want people refreshing too soon, like your PM who asks "are we done yet?" five minutes after the task was delivered.
2. Refresh Token Endpoint
The refresh endpoint accepts a refresh_token
and:
- Validates that
nbf
≤ current time. - Issues a new pair of tokens (access + refresh).
- Revokes the old refresh token (single-use only, like that one time lunch coupon you forgot to redeem).
By revoking the old refresh token, we ensure attackers can't reuse a stolen refresh token.
3. Logout Endpoint
The logout flow accepts the Authorization: Bearer <access_token>
header and:
- Revokes the access token.
- Uses
link_id
to find and revoke the associated refresh token.
This prevents both access and refresh tokens from being used again.
Dependency Injection
We use Google Wire (https://github.com/google/wire) to handle dependency injection in the authentication module.
The snippet below defines a UseCaseSet
var UseCaseSet = wire.NewSet(
provider.ConfigSet,
// Configuration
provider.ProvideRedisClient,
provider.ProvideRedisAdapter,
// Repositories
provider.ProvideUserRepository,
provider.ProvideTokenGenerator,
// Use Cases
provider.ProvideAuthUseCase,
// Container
provider.ProvideContainer,
)
func InitializeContainer() provider.Container {
panic(wire.Build(UseCaseSet))
}
This approach:
- Removes manual dependency wiring.
- Makes components easier to test (swap in mocks).
- Avoids accidental "hidden"”" dependencies.
- Keeps our codebase modular.
In short, UseCaseSet is the blueprint, Wire is the builder, and Container is the final assembled product.
Links, if you're curious
What's Next?
Now that authentication is in place, it's time to bring it to life in the presentation layer.
Here, our APIs will finally be able to greet the outside world. And yes, we'll even make it shake hands politely with Swagger docs.
Top comments (0)