Skip to content

Local Authentication

This tutorial covers the authentication layer in LenserFight. You will understand how users authenticate, how sessions are managed, and how workspace security works.

Prerequisites


Authentication modes

ModeData sourceAuth methodUse case
File modeDATA_SOURCE=fileAuto-login as Local DevSolo development, no auth needed
Supabase modeDATA_SOURCE=supabaseSupabase Auth (GoTrue)Full multi-user authentication

File mode authentication

In file mode, authentication is bypassed entirely. The app creates a synthetic Local Dev user on startup. No login screen appears.

This is controlled by the DATA_SOURCE=file environment variable.


Supabase Auth (GoTrue)

In Supabase mode, LenserFight uses GoTrue for authentication. GoTrue is a JWT-based auth server that supports:

  • Email/password authentication
  • Magic link (passwordless email)
  • OAuth providers (Google, GitHub, Discord)
  • Phone/SMS authentication

JWT flow

1. User submits credentials → Auth app (port 3004)
2. Auth app calls GoTrue    → POST /auth/v1/token
3. GoTrue validates          → returns JWT (access_token + refresh_token)
4. Browser stores tokens     → localStorage / cookies
5. API requests include      → Authorization: Bearer <access_token>
6. Supabase validates JWT    → PostgREST checks auth.uid() in RLS policies
7. Token refresh             → automatic via refresh_token before expiry

Token structure

The JWT payload contains:

json
{
  "sub": "user-uuid",
  "email": "user@example.com",
  "role": "authenticated",
  "aud": "authenticated",
  "exp": 1700000000,
  "app_metadata": {
    "provider": "email"
  },
  "user_metadata": {
    "handle": "alice",
    "display_name": "Alice"
  }
}

Session handling

The Supabase client manages sessions automatically:

typescript
import { supabase } from '@lenserfight/data';

// Get current session
const { data: { session } } = await supabase.auth.getSession();

// Get current user
const { data: { user } } = await supabase.auth.getUser();

// Listen for auth changes
supabase.auth.onAuthStateChange((event, session) => {
  if (event === 'SIGNED_IN') { /* handle login */ }
  if (event === 'SIGNED_OUT') { /* handle logout */ }
  if (event === 'TOKEN_REFRESHED') { /* token auto-refreshed */ }
});

OAuth providers

Configuring OAuth locally

OAuth providers are configured in supabase/config.toml:

toml
[auth.external.google]
enabled = true
client_id = "your-google-client-id"
secret = "your-google-client-secret"
redirect_uri = "http://localhost:54321/auth/v1/callback"

[auth.external.github]
enabled = true
client_id = "your-github-client-id"
secret = "your-github-client-secret"
redirect_uri = "http://localhost:54321/auth/v1/callback"

Triggering OAuth login

typescript
await supabase.auth.signInWithOAuth({
  provider: 'google',
  options: {
    redirectTo: 'http://localhost:3000/auth/callback',
  },
});

Supported OAuth providers

ProviderConfig keyNotes
Googleauth.external.googleRequires Google Cloud Console project
GitHubauth.external.githubRequires GitHub OAuth app
Discordauth.external.discordRequires Discord developer app

Auth app architecture

The auth app (apps/auth/) handles authentication UI flows:

apps/auth/ (port 3004)
  ├── Login page         → email/password, magic link, OAuth
  ├── Signup page        → registration with handle selection
  ├── Callback page      → OAuth redirect handler
  ├── Reset password     → password reset flow
  └── Verify email       → email verification

The web app redirects to the auth app for login/signup, and the auth app redirects back after successful authentication.

Auth URL configuration

bash
# .env.local
AUTH_BASE_URL=http://localhost:3004

Role and permission model

LenserFight uses a layered permission model:

Platform roles

RoleDescriptionRLS context
anonUnauthenticated visitorRead-only public data
authenticatedLogged-in userOwn data + public data
service_roleBackend servicesFull access (bypasses RLS)

Workspace roles

RolePermissions
OwnerFull CRUD, settings, billing, member management
AdminCRUD, member management, no billing
MemberCreate/edit own content, read shared content
ViewerRead-only access

Checking permissions

typescript
import { useAuth } from '@lenserfight/shared';

function ProtectedComponent() {
  const { user, isOwner, isAdmin } = useAuth();

  if (!user) return <LoginPrompt />;
  if (!isOwner) return <AccessDenied />;

  return <OwnerDashboard />;
}

Workspace security

Data isolation

Each workspace has isolated data enforced by RLS:

sql
-- Users can only read their workspace's data
CREATE POLICY "workspace_isolation"
  ON public.lenses
  FOR SELECT
  USING (
    workspace_id IN (
      SELECT workspace_id FROM public.workspace_members
      WHERE user_id = auth.uid()
    )
  );

Developer tokens

For CI/CD and API access, users can create developer tokens:

bash
lf auth token create --name "CI Pipeline" --expires 30d

Tokens are stored in authz.developer_tokens with configurable expiration and revocation.


Local development tips

Using seed accounts

After pnpm supabase:db:reset, log in with seed accounts:

EmailPasswordNotes
alice@lenserfight.localCheck seed scriptAdmin user with sample data
bob@lenserfight.localCheck seed scriptRegular user

Bypassing auth in development

Set local auth testing setup in .env.local to skip real auth calls in development.

Inspecting tokens

Use jwt.io to decode and inspect JWT tokens from browser DevTools:

  1. Open DevTools → Application → Local Storage
  2. Find the sb-<project>-auth-token key
  3. Copy the access_token value
  4. Paste into jwt.io

Troubleshooting

SymptomCauseFix
Redirect loop on loginAuth URL mismatchVerify AUTH_BASE_URL matches the auth app port
JWT expiredToken not refreshingClear browser storage and re-login
OAuth callback failsRedirect URI mismatchUpdate OAuth app redirect URI to match local Supabase
permission denied on queriesRLS policy issueCheck auth.uid() and policy conditions
Invalid API keyWrong anon keyCopy from pnpm supabase start output

Next steps