mirror of
https://github.com/prdlk/dotfiles.git
synced 2026-11-08 04:57:36 +00:00
shell: fish functions -> ~/.local/bin executables + bash/zsh ports; manage ~/.agents
- 16 cross-shell utilities converted to sh/bash chezmoi executables: ai c2p cp-appid extract_agents fab gdc gh-sonr-org-migrate ghg-clone ghn gho git-summary launch_omp_livediff_overlay lc-elir mdcp mdp pj (ghg-clone not named ghg: unrelated cargo binary owns that name) - cwd/history helpers stay per-shell: yy c cd clip-last + ghg/ghn/gho cd-wrappers in bash/functions/ and zsh/functions/, loaded by rc files - fish keeps only shell-state functions; converted ones now hit PATH - cp-appid ignore gating moved to .local/bin path - dot_agents/skills: 82 skills materialized from dotstate storage so dot_claude skill symlinks resolve on fresh bootstrap; dotstate profile/symlinks.json released .agents/skills .bashrc .zshrc
This commit is contained in:
1 parent
6776366ce6
commit
6406208b35
637 files changed
+100297
-497
No files matched your search
@@ -0,0 +1,422 @@
|
||||
# Authentication and Sessions
|
||||
|
||||
## What This Covers
|
||||
|
||||
How to remember things about a browser between requests and how to identify a user. Read this when the task involves:
|
||||
|
||||
- Storing per-browser state across requests (login, cart, "I have submitted this form")
|
||||
- Adding a credentials login flow or an OAuth provider
|
||||
- Protecting routes with `requireAuth()` or stacking authorization checks
|
||||
- Reading or writing `Session`, `Auth`, or other identity-related context values
|
||||
- Logging in, logging out, or rotating session IDs
|
||||
|
||||
For raw cookies that are not session-backed (theme, locale, dismissed-banner), see `createCookie` in this file plus the broader `Package Map` in `SKILL.md`.
|
||||
|
||||
## Sessions vs Plain Cookies
|
||||
|
||||
Reach for `remix/session` when state is sensitive, must be tamper-resistant, or represents the identity of a request: who is logged in, which form a browser already submitted, what items are in a cart. Sessions sign or encrypt their backing cookie with a server-held secret and give you a typed `Session` object you can `get`, `set`, `flash`, `unset`, and `regenerateId`.
|
||||
|
||||
Reach for `remix/cookie` directly when the browser is allowed to carry the value and the server does not need session semantics. This often means preferences (theme, locale, dismissed banner), but a signed cookie can also be fine for small low-risk values where you truly only need one cookie-shaped fact and do not need `Session` helpers.
|
||||
|
||||
If a malicious user editing the value would be a bug, or if the value needs server-managed lifecycle, reach for a session.
|
||||
|
||||
### Quick chooser
|
||||
|
||||
| Need | Best fit | Why |
|
||||
| ------------------------------------------------------------------- | --------------- | -------------------------------------------------- |
|
||||
| Theme, locale, dismissed banner | `remix/cookie` | Browser-controlled preference |
|
||||
| Small signed hint with minimal lifecycle | `remix/cookie` | One value, no `Session` helpers needed |
|
||||
| "This browser already submitted", cart, flash messages, login state | `remix/session` | Tamper-sensitive, server-managed per-browser state |
|
||||
| "One real person only", ownership, durable identity | account/auth | Cookies or sessions alone do not prove personhood |
|
||||
|
||||
## Session Setup
|
||||
|
||||
### Create a session cookie
|
||||
|
||||
```typescript
|
||||
import { createCookie } from 'remix/cookie'
|
||||
|
||||
let sessionSecret = process.env.SESSION_SECRET
|
||||
if (!sessionSecret && process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('SESSION_SECRET is required')
|
||||
}
|
||||
|
||||
export let sessionCookie = createCookie('session', {
|
||||
secrets: [sessionSecret ?? 'test-only-secret'],
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 2592000, // 30 days
|
||||
path: '/',
|
||||
})
|
||||
```
|
||||
|
||||
The cookie should always be `httpOnly`, default to `sameSite: 'Lax'`, and be `secure` in production. Demo defaults like `'s3cr3t'` are fine in tests but should never reach production — fail fast when the secret is missing.
|
||||
|
||||
### Create session storage
|
||||
|
||||
```typescript
|
||||
// Filesystem storage
|
||||
import { createFsSessionStorage } from 'remix/session-storage/fs'
|
||||
export let sessionStorage = createFsSessionStorage('./tmp/sessions')
|
||||
|
||||
// Memory storage (for tests)
|
||||
import { createMemorySessionStorage } from 'remix/session-storage/memory'
|
||||
export let sessionStorage = createMemorySessionStorage()
|
||||
```
|
||||
|
||||
### Add session middleware
|
||||
|
||||
```typescript
|
||||
import { session } from 'remix/middleware/session'
|
||||
|
||||
let router = createRouter({
|
||||
middleware: [
|
||||
session(sessionCookie, sessionStorage),
|
||||
// ... other middleware
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Using sessions in handlers
|
||||
|
||||
```typescript
|
||||
import { Session } from 'remix/session'
|
||||
|
||||
async function handler({ get }) {
|
||||
let session = get(Session)
|
||||
|
||||
// Read
|
||||
let userId = session.get('userId')
|
||||
|
||||
// Write
|
||||
session.set('userId', 42)
|
||||
|
||||
// Flash (read once, then cleared)
|
||||
session.flash('message', 'Settings saved!')
|
||||
let message = session.get('message') // returns and clears
|
||||
|
||||
// Remove a key
|
||||
session.unset('userId')
|
||||
|
||||
// Regenerate session ID (after login/logout)
|
||||
session.regenerateId(true)
|
||||
}
|
||||
```
|
||||
|
||||
### Sessions for non-auth state
|
||||
|
||||
Sessions are not just for login. They are the right place to store any tamper-sensitive per-browser fact: which form a browser already submitted, how many free actions are left in a trial, which feature flags a tester opted into, what items are in a cart.
|
||||
|
||||
```typescript
|
||||
async function submit({ get }) {
|
||||
let session = get(Session)
|
||||
if (session.get('hasSubmitted')) {
|
||||
return render(<AlreadySubmittedPage />, { status: 409 })
|
||||
}
|
||||
|
||||
let parsed = s.parseSafe(submitSchema, get(FormData))
|
||||
if (!parsed.success) {
|
||||
return render(<SubmitPage errors={parsed.issues} />, { status: 400 })
|
||||
}
|
||||
|
||||
await saveSubmission(parsed.value)
|
||||
session.set('hasSubmitted', true)
|
||||
session.flash('message', 'Thanks for submitting!')
|
||||
|
||||
return redirect(routes.thanks.href())
|
||||
}
|
||||
```
|
||||
|
||||
Notice that there is no manual `Set-Cookie` plumbing in the action — the session middleware handles that, and the handler returns an ordinary `Response`. Per-browser state enforced this way is still bypassable by clearing cookies; if the guarantee needs to survive that, you also need an account (see auth providers below).
|
||||
|
||||
## Auth Middleware
|
||||
|
||||
### Basic setup
|
||||
|
||||
```typescript
|
||||
import { auth, createSessionAuthScheme } from 'remix/middleware/auth'
|
||||
import { Session } from 'remix/session'
|
||||
import { Database } from 'remix/data-table'
|
||||
|
||||
export function loadAuth() {
|
||||
return auth({
|
||||
schemes: [
|
||||
createSessionAuthScheme({
|
||||
read(session) {
|
||||
let data = session.get('auth')
|
||||
return data ?? null
|
||||
},
|
||||
async verify(value, context) {
|
||||
let db = context.get(Database)
|
||||
return (await db.find(users, value.userId)) ?? null
|
||||
},
|
||||
invalidate(session) {
|
||||
session.unset('auth')
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Reading auth state
|
||||
|
||||
```typescript
|
||||
import { Auth } from 'remix/middleware/auth'
|
||||
|
||||
function handler({ get }) {
|
||||
let auth = get(Auth)
|
||||
|
||||
if (auth.ok) {
|
||||
// User is authenticated
|
||||
let user = auth.identity
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Credentials Auth
|
||||
|
||||
### Define a credentials provider
|
||||
|
||||
```typescript
|
||||
import { createCredentialsAuthProvider, verifyCredentials, completeAuth } from 'remix/auth'
|
||||
import * as s from 'remix/data-schema'
|
||||
import * as f from 'remix/data-schema/form-data'
|
||||
|
||||
let loginSchema = f.object({
|
||||
email: f.field(s.defaulted(s.string(), '')),
|
||||
password: f.field(s.defaulted(s.string(), '')),
|
||||
})
|
||||
|
||||
export let passwordProvider = createCredentialsAuthProvider({
|
||||
parse(context) {
|
||||
let formData = context.get(FormData)
|
||||
return s.parse(loginSchema, formData)
|
||||
},
|
||||
async verify({ email, password }, context) {
|
||||
let db = context.get(Database)
|
||||
let user = await db.findOne(users, { where: { email } })
|
||||
if (!user || !(await verifyPassword(password, user.password_hash))) {
|
||||
return null
|
||||
}
|
||||
return user
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Login action
|
||||
|
||||
```typescript
|
||||
import { verifyCredentials, completeAuth } from 'remix/auth'
|
||||
import { redirect } from 'remix/response/redirect'
|
||||
|
||||
async action(context) {
|
||||
let user = await verifyCredentials(passwordProvider, context)
|
||||
|
||||
if (user == null) {
|
||||
let session = context.get(Session)
|
||||
session.flash('error', 'Invalid email or password.')
|
||||
return redirect(routes.auth.login.href())
|
||||
}
|
||||
|
||||
let session = completeAuth(context)
|
||||
session.set('auth', { userId: user.id })
|
||||
|
||||
return redirect(routes.home.href())
|
||||
},
|
||||
```
|
||||
|
||||
### Logout action
|
||||
|
||||
```typescript
|
||||
import { Session } from 'remix/session'
|
||||
import { redirect } from 'remix/response/redirect'
|
||||
|
||||
function logout(context) {
|
||||
let session = context.get(Session)
|
||||
session.unset('auth')
|
||||
session.regenerateId(true)
|
||||
return redirect(routes.home.href())
|
||||
}
|
||||
```
|
||||
|
||||
## OAuth / External Auth
|
||||
|
||||
### Create providers
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createAtmosphereAuthProvider,
|
||||
createGoogleAuthProvider,
|
||||
createGitHubAuthProvider,
|
||||
startExternalAuth,
|
||||
finishExternalAuth,
|
||||
completeAuth,
|
||||
refreshExternalAuth,
|
||||
} from 'remix/auth'
|
||||
|
||||
let googleProvider = createGoogleAuthProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
redirectUri: new URL(routes.auth.google.callback.href(), origin),
|
||||
})
|
||||
|
||||
let githubProvider = createGitHubAuthProvider({
|
||||
clientId: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
redirectUri: new URL(routes.auth.github.callback.href(), origin),
|
||||
})
|
||||
|
||||
let atmosphereSessionSecret = process.env.ATMOSPHERE_SESSION_SECRET
|
||||
if (!atmosphereSessionSecret && process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('ATMOSPHERE_SESSION_SECRET is required')
|
||||
}
|
||||
|
||||
let atmosphereProvider = createAtmosphereAuthProvider({
|
||||
clientId: 'https://app.example.com/oauth/client-metadata.json',
|
||||
redirectUri: new URL(routes.auth.atmosphere.callback.href(), origin),
|
||||
sessionSecret: atmosphereSessionSecret ?? 'test-only-secret',
|
||||
})
|
||||
```
|
||||
|
||||
For Atmosphere-compatible atproto OAuth, create the provider once, call `atmosphereProvider.prepare(handleOrDid)` before `startExternalAuth(...)`, then pass the same module-scope provider to `finishExternalAuth(...)` and `refreshExternalAuth(...)`.
|
||||
|
||||
### OAuth controller
|
||||
|
||||
```typescript
|
||||
import { createController } from 'remix/router'
|
||||
|
||||
export default createController(routes.auth.google, {
|
||||
actions: {
|
||||
// GET /auth/google — redirect to Google
|
||||
async index(context) {
|
||||
return await startExternalAuth(googleProvider, context, {
|
||||
returnTo: context.url.searchParams.get('returnTo'),
|
||||
})
|
||||
},
|
||||
|
||||
// GET /auth/google/callback — handle redirect back
|
||||
async callback(context) {
|
||||
let { result, returnTo } = await finishExternalAuth(googleProvider, context)
|
||||
|
||||
let db = context.get(Database)
|
||||
let { user, authAccount } = await resolveExternalAuth(db, result)
|
||||
|
||||
let session = completeAuth(context)
|
||||
session.set('auth', {
|
||||
userId: user.id,
|
||||
loginMethod: result.provider,
|
||||
authAccountId: authAccount.id,
|
||||
})
|
||||
|
||||
return redirect(returnTo ?? routes.account.index.href())
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Refresh stored provider tokens
|
||||
|
||||
Use `refreshExternalAuth(provider, tokens)` when an app has stored OAuth/OIDC tokens and needs a fresh access token from a refresh token. Built-in OIDC providers, X, and Atmosphere support refresh-token exchange. If the provider does not rotate the refresh token, the refreshed bundle preserves the current one.
|
||||
|
||||
```typescript
|
||||
async function refreshGoogleTokens({ get }) {
|
||||
let db = get(Database)
|
||||
let account = await db.findOne(authAccounts, { where: { provider: 'google' } })
|
||||
if (!account) return null
|
||||
|
||||
let refreshed = await refreshExternalAuth(googleProvider, account.tokens)
|
||||
await db.update(authAccounts, account.id, { tokens: refreshed.tokens })
|
||||
|
||||
return refreshed.tokens
|
||||
}
|
||||
```
|
||||
|
||||
## Protecting Routes
|
||||
|
||||
### Controller middleware protection
|
||||
|
||||
Apply `requireAuth()` as controller middleware to every action in one controller:
|
||||
|
||||
```typescript
|
||||
import { createController } from 'remix/router'
|
||||
import { requireAuth } from 'remix/middleware/auth'
|
||||
|
||||
export default createController(routes.account, {
|
||||
middleware: [requireAuth()],
|
||||
actions: {
|
||||
index() {
|
||||
/* guaranteed authenticated */
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Nested route maps need their own explicit protection:
|
||||
|
||||
```typescript
|
||||
// app/router.ts
|
||||
router.map(routes.account, accountController)
|
||||
router.map(routes.account.settings, accountSettingsController)
|
||||
|
||||
// app/actions/account/settings/controller.tsx
|
||||
export default createController(routes.account.settings, {
|
||||
middleware: [requireAuth()],
|
||||
actions: {
|
||||
index() {
|
||||
/* guaranteed authenticated */
|
||||
},
|
||||
update() {
|
||||
/* guaranteed authenticated */
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Stacking middleware
|
||||
|
||||
Combine auth checks with role checks:
|
||||
|
||||
```typescript
|
||||
export default createController(routes.admin, {
|
||||
middleware: [requireAuth(), requireAdmin()],
|
||||
actions: {
|
||||
index() {
|
||||
/* requires auth + admin */
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Action middleware protection
|
||||
|
||||
Apply middleware to a single route:
|
||||
|
||||
```typescript
|
||||
import { Auth, requireAuth } from 'remix/middleware/auth'
|
||||
|
||||
router.get(routes.account.index, {
|
||||
middleware: [requireAuth()],
|
||||
handler(context) {
|
||||
let auth = context.get(Auth)
|
||||
return render(<AccountPage identity={auth.identity} />)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Redirect on auth failure
|
||||
|
||||
```typescript
|
||||
import { requireAuth } from 'remix/middleware/auth'
|
||||
import { redirect } from 'remix/response/redirect'
|
||||
|
||||
export function requireAuthRedirect() {
|
||||
return requireAuth({
|
||||
onFailure(context) {
|
||||
let returnTo = encodeURIComponent(context.url.pathname)
|
||||
return redirect(routes.auth.login.href() + `?returnTo=${returnTo}`, 303)
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
Reference in new issue
Block a user