Build in Public : Authenticating Valyent's CLI.
Roses are red. Violets are blue. AdonisJS is sweet. And so is Go.
At Valyent, we are working on a suite of software that brings joy to developers. In this series of articles, we share our learnings, struggles and successes while trying to make this company a thing.
Our cloud platform allows to deploy applications just as smoothly as butter on hot toast.
From a technical perspective, it consists in :
a web application developed with AdonisJS (and React, to please our soydev’s appetite), interacting with a Ravel cluster: our microVMs orchestrator (more on that subject in future articles) ;
a CLI developed in Go (with Charm TUI libraries, and Cobra) ;
and some other modules developed in Go, like a tailored Docker registry, etc.
To deploy an application, we initially intend to support two ways: through a CLI, our directly from your GitHub repository.
For this article, let's focus on the CLI approach we adopted.
The first step in using our CLI is authentication. In this article, I’ll share with you how we implemented a first version of CLI authentication. You will find all the matching code on our GitHub repository.
Before being able to interact with Software Citadel from your terminal, you need to authenticate. We've kept it simple.
citadel auth login
And the CLI kicks into action. It requests a unique session ID from our AdonisJS backend. This ID is stored in Redis, marked as 'pending'.
sessionId, err := GetAuthenticationSessionId()
if err != nil {
// Handle error
}
This ID is stored in Redis, marked as 'pending':
await redis.set(sessionId, 'pending')
But let's take a closer peek under the hood at our AdonisJS backend.
The AdonisJS backend
The routes powering our authentication process are elegantly simple.
// ./app/auth/routes.ts
import router from '@adonisjs/core/services/router'
const CliAuthController = () => import('#auth/controllers/cli_auth_controller')
// CLI authentication routes
router.get('/auth/cli', [CliAuthController, 'getSession'])
router.get('/auth/cli/:sessionId', [CliAuthController, 'show'])
router.post('/auth/cli/:sessionId', [CliAuthController, 'handle'])
router.get('/auth/cli/wait/:sessionId', [CliAuthController, 'wait'])
router.get('/auth/cli/check', [CliAuthController, 'check'])
Each route maps to a specific method in our CliAuthController, handling different stages of the authentication dance:
GET
/auth/cli
generates that unique session ID when you start the login process.GET
/auth/cli/:sessionId
serves up the web page where you confirm your login.POST
/auth/cli/:sessionId
handles the actual login and token creation.GET
/auth/cli/wait/:sessionId
is constantly polled by our CLI, checking if you've completed the login.GET
/auth/cli/check
verifies if your token is still valid when you run 'citadel check'.Here's where it gets interesting. We automatically open your default browser. You're greeted with a web page, asking you to continue with your logged in account.
Our CliAuthController is where the real magic happens. It's a symphony of user authentication, token generation, and state management. Here's what it looks like in full:
// ./app/auth/controllers/cli_controller.ts
import User from '#common/database/models/user'
import { cuid } from '@adonisjs/core/helpers'
import { HttpContext } from '@adonisjs/core/http'
import redis from '@adonisjs/redis/services/main'
export default class CliAuthController {
async getSession({ response }: HttpContext) {
const sessionId = cuid()
await redis.set(sessionId, 'pending')
return response.ok({ sessionId })
}
async show({ params, inertia }: HttpContext) {
const sessionId = await redis.get(params.sessionId)
if (sessionId === 'pending') {
return inertia.render('auth/cli')
}
return inertia.render('auth/cli', { success: true })
}
async handle({ auth, response, params }: HttpContext) {
try {
const token = await User.authTokens.create(auth.user!)
const sessionId = await redis.get(params.sessionId)
if (sessionId !== 'pending') {
throw new Error()
}
await redis.set(params.sessionId, token.value!.release())
return response.redirect().back()
} catch (error) {
console.log(error)
return response.badRequest('Bad request')
}
}
async wait({ params, response }: HttpContext) {
const token = await redis.get(params.sessionId)
if (token === 'pending') {
return response.ok({ status: 'pending' })
}
await redis.del(params.sessionId)
return response.ok({ status: 'done', token })
}
async check({ auth, response }: HttpContext) {
try {
await auth.use('api').authenticate()
return response.json({ authenticated: true })
} catch {
return response.json({ authenticated: false })
}
}
}
So now, you must have a good idea of what our backend does.
The Go CLI
Now back to our CLI, we still need to show you how we actually open a link in the browser of our users. Here is how:
// opener.go
package util
import (
"fmt"
"os/exec"
"runtime"
)
func OpenInBrowser(url string) error {
switch runtime.GOOS {
case "linux":
return exec.Command("xdg-open", url).Start()
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
return exec.Command("open", url).Start()
default:
return fmt.Errorf("unsupported platform")
}
}
// login.go
url := api.RetrieveApiBaseUrl() + "/auth/cli/" + sessionId
fmt.Printf("\nOpening browser to %s\n", url)
msg.SetStatus("Opening browser...")
util.OpenInBrowser(api.RetrieveApiBaseUrl() + "/auth/cli/" + sessionId)
While you're logging in through the browser, our CLI patiently waits.
token, err := WaitForLogin(sessionId)
if err != nil {
// Handle error
}
It's constantly checking with the server, asking, "Is the user done yet?"
Once you successfully authenticate in the browser, our AdonisJS backend springs into action:
const token = await User.authTokens.create(auth.user!)
await redis.set(params.sessionId, token.value!.release())
Our Go-based CLI, ever vigilant, notices this change. It fetches the token and securely stores it on your local machine. Just like that, you're logged in and ready to deploy.
Want to check your authentication status later? Just run 'citadel check'. It's a simple process:
func IsLoggedIn() bool {
token, err := util.RetrieveTokenFromConfig()
if err != nil {
return false
}
return checkAuthenticationTokenAgainstAPI(token)
}
And when it's time to log out? 'citadel logout' has you covered:
func Logout() error {
return util.RemoveConfigFile()
}
This whole process - from CLI to browser to backend and back - is designed with developers in mind. It's secure, efficient, and most importantly, it just works.
We're leveraging the strengths of both AdonisJS and Go here. AdonisJS provides robust, out-of-the-box authentication. Go gives us a lightning-fast, cross-platform CLI. Together, they create an authentication flow that's smooth as butter.
And remember, this is just the beginning. We're constantly refining and improving Software Citadel. Our goal? To make deployments so smooth, you'll hardly notice them happening.
If our journey resonates with you, feel free to come talking to us on our Discord server.
Good article, thank you for sharing that!