Following on from the previous post: three shells sharing one set of logic packages. What about auth?
The web wants an HTTP-only cookie — sent automatically, unreadable from JS, safe against XSS. The CLI has no browser and no cookie jar, so it wants a bearer token.
The credential formats have nothing in common. That is not a reason to write two APIs.
Server: one middleware, two channels
1 | export async function getAuthenticatedUser(req: Request) { |
Either channel passing attaches the resolved user to the request context. Everything downstream reads userId and never asks where it came from.
The order matters: bearer first, cookie second. A request carrying an explicit token is stating its intent. Checking the cookie first silently picks the wrong identity in any situation where both are present — debugging a CLI request from inside a logged-in browser, for instance.
Core: inject the credential provider
The interesting half is the client. packages/core has one API client, used by every shell, and each shell obtains credentials differently.
Do not detect the environment inside the library. Let the shell inject the method:
1 | type AuthHeaderProvider = () => string | null | Promise<string | null> |
Each shell configures it once at startup:
- CLI:
api.configureAuth(() => 'Bearer ' + readTokenFromConfig()) - Web: nothing to configure; the default provider returns
nulland the browser attaches the cookie - Electron: depends on whether it talks to the cloud or a local server
The provider may return a promise, which matters more than it looks: that is where you silently refresh an expired token without the caller noticing.
Feature code stays clean
1 | import { api } from '@my-project/core' |
On the web this call carries a cookie. In the CLI it carries a bearer token. The function does not know the difference and should not.
It is the same move as the FileSaver interface in the previous post: the shell injects the difference, the shared package only knows an interface.
flowchart TD
subgraph shells["Shells configure their own credentials"]
W["Web
nothing to configure, browser sends cookie"]
C["CLI
configureAuth returns a bearer header"]
end
W --> API["core: ApiClient
one request()"]
C --> API
API --> MW["Server middleware
bearer first, then cookie"]
MW --> U["userId on the request context"]
Where the CLI token comes from
All of the above assumes readTokenFromConfig() finds something. How that token got there is the step most designs skip.
Do not make users copy a long-lived token out of a settings page. Those tokens usually never expire, sit in plaintext in ~/.config, and leak silently.
Use a device authorization flow instead: the CLI opens a local callback port, sends the user to the already-logged-in web app to approve, and receives a token that is short-lived, revocable, and tied to a device label. The user never handles the credential, the server knows which device was authorized and when, and a single machine can be revoked on its own.
What each channel still needs
The cookie channel needs CSRF protection. Automatic attachment is the convenience and the risk: requests originating from other sites carry the cookie too. At minimum set the session cookie to SameSite=Lax, and add a CSRF token for cross-site write paths. The bearer channel is immune, because nobody else can add that header on your behalf.
The bearer channel needs revocation. If verifyToken is pure JWT signature verification, an issued token cannot be recalled before it expires. A lost laptop means waiting it out. Either look the token up in a store (and pay for the query) or keep expiry short and add refresh.
The two channels may not deserve the same permissions. A web session is a human at a screen. A CLI token usually ends up in a CI script. Scoping CLI tokens — read-only, or limited to one project — is worth doing, and the middleware should attach the credential’s origin alongside userId so downstream code can tighten access accordingly.
None of the three changes the shape of the one-client design. They are what you add on top of it.