# SpartanAuth – AI Coding Context: Login Widget Integration

> **How to use this file with your AI coding agent:**
> Place this file at your project root as `AGENTS.md`. Most agents will pick it up automatically.
>
> | Agent | What to do |
> |---|---|
> | GitHub Copilot | Place at project root as `AGENTS.md` — auto-loaded in agent mode |
> | Windsurf | Place at project root as `AGENTS.md` — auto-loaded |
> | OpenAI Codex CLI | Place at project root as `AGENTS.md` — auto-loaded |
> | Cline | Place at project root as `AGENTS.md` — auto-detected |
> | Claude Code | Place at project root as `AGENTS.md`, then add `@AGENTS.md` to your `CLAUDE.md` |
> | Gemini CLI | Place at project root as `AGENTS.md`, then add `"AGENTS.md"` to `context.fileName` in `.gemini/settings.json` |
> | Cursor | Copy content into `.cursor/rules/spartanauth.mdc` with `alwaysApply: true` in the frontmatter |
> | Aider | Place at project root, then add `read: AGENTS.md` to `.aider.conf.yml` |
> | Any other agent | Attach this file (or paste its contents) directly in your chat session |

---

## What Is the Login Widget?

`@masonitestudios/spartanauth-widgets` is an npm package that ships three framework-agnostic **web components** (custom elements). They connect to a SpartanAuth backend and can be embedded in any modern web application — Vue, React, SolidJS, or plain HTML.

| Custom Element | Purpose |
|---|---|
| `<spartan-login>` | Full login form: password, WebAuthn, OTP/MFA, self-sign-up, password reset |
| `<spartan-account-settings>` | Authenticated settings panel for managing passkeys and MFA |

---

## Installation

```bash
npm install @masonitestudios/spartanauth-widgets
```

Import once in your application entry point to register all custom elements:

```ts
import '@masonitestudios/spartanauth-widgets';
```

---

## Login Widget: Attributes

Place `<spartan-login>` wherever you want the login form to appear.

```html
<spartan-login
  domain="https://api.spartanauth.com"
  sector="your-sector-id"
  start-mode="password"
  locale="en"
  redirect=""
  styles="">
</spartan-login>
```

| Attribute | Default                       | Description |
|---|-------------------------------|---|
| `domain` | `https://api.spartanauth.com` | SpartanAuth API base URL |
| `sector` | *(admin sector)*              | Sector ID for your application. Get this from the SpartanAuth dashboard. |
| `start-mode` | `password`                    | Initial mode: `password` or `webauthn` |
| `locale` | `en`                          | UI language: `en`, `fr`, `es`, `ja` |
| `redirect` | `""`                          | URL to navigate to after successful login. Leave empty to suppress automatic navigation and handle the `spartan-login` event yourself instead. |
| `styles` | `""`                          | CSS string injected into the widget's shadow DOM for custom styling |

**Most applications use a single fixed `sector` ID** — you get it from the SpartanAuth admin dashboard when creating your application's sector.

---

## Login Widget: Events and Token Storage

On a successful login the widget:
1. Stores the JWT in `localStorage` under the key `spartan-token`
2. Dispatches a `spartan-login` **CustomEvent** on the element with `{ token, transactionID }` in `event.detail`

```ts
element.addEventListener('spartan-login', (event: CustomEvent) => {
  const { token, transactionID } = event.detail;
  // token is also in localStorage['spartan-token']
});
```

**Always listen for the `spartan-login` event** rather than polling `localStorage`, since the widget handles all the multi-step flows (MFA, sign-up, password reset) before firing this event.

---

## Styling the Widget

The widget renders inside Shadow DOM. Override styles via the `styles` attribute:

```html
<spartan-login
  styles=".login-frame button { background-color: #ff6600; color: white; }">
</spartan-login>
```

---

## Reactivity Gotchas: Web Components in Frameworks

Because `<spartan-login>` is a native web component, it is **not integrated with Vue's, React's, or SolidJS's reactivity systems**. You must use each framework's lifecycle hooks to wire up event listeners and respond to attribute changes.

### ⚠️ Key rules:

1. **Event listeners must be attached after mount** — the element does not exist in the DOM before the component mounts.
2. **Event listeners must be removed on unmount** — otherwise they leak.
3. **Changing locale (or other attributes) requires a re-mount** — use a key-based remount pattern to force the widget to reinitialize when attributes that the widget reads on creation change.

---

### Vue 3 Integration

```vue
<template>
  <spartan-login
    :key="widgetKey"
    ref="spartanLoginRef"
    domain="https://api.spartanauth.com"
    sector="your-sector-id"
    start-mode="password"
    :locale="currentLocale"
    redirect="">
  </spartan-login>
</template>

<script setup lang="ts">
import '@masonitestudios/spartanauth-widgets';
import { ref, watch, onBeforeUnmount } from 'vue';
import { useRouter } from 'vue-router';

const router = useRouter();
const spartanLoginRef = ref<HTMLElement | null>(null);
const currentLocale = ref(navigator.language.slice(0, 2) || 'en');
// Increment this key to force the widget to remount (e.g., when locale changes)
const widgetKey = ref(0);

function handleLogin() {
  const token = localStorage.getItem('spartan-token');
  if (token) {
    // Token is available — navigate to protected area
    router.push('/app');
  }
}

// Attach/detach the event listener reactively whenever the widget mounts or remounts.
// Using watch on the ref handles both the initial mount and any remounts caused by
// key changes — this is critical because the DOM element is replaced on remount.
watch(spartanLoginRef, (newEl, oldEl) => {
  if (oldEl) {
    oldEl.removeEventListener('spartan-login', handleLogin);
  }
  if (newEl) {
    newEl.addEventListener('spartan-login', handleLogin);
  }
});

// Belt-and-suspenders: clean up on route leave
onBeforeUnmount(() => {
  spartanLoginRef.value?.removeEventListener('spartan-login', handleLogin);
});

// Force a remount whenever locale changes so the widget re-reads the locale attribute
watch(currentLocale, () => {
  widgetKey.value++;
});
</script>
```

**Why `:key` matters:** Vue reuses DOM nodes when possible. Incrementing `:key` forces Vue to destroy and recreate the element, which causes the web component to re-read its attributes (including `locale`). Without this, updating `locale` on the element will not take effect because the widget initializes its i18n state once on creation.

---

### React Integration

```tsx
import '@masonitestudios/spartanauth-widgets';
import { useRef, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';

// Extend JSX types so TypeScript knows about the custom element
declare global {
  namespace JSX {
    interface IntrinsicElements {
      'spartan-login': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
        domain?: string;
        sector?: string;
        'start-mode'?: string;
        locale?: string;
        redirect?: string;
        styles?: string;
      };
    }
  }
}

export function LoginPage() {
  const navigate = useNavigate();
  const widgetRef = useRef<HTMLElement>(null);
  const [locale] = useState(() => navigator.language.slice(0, 2) || 'en');
  // Use a key to force remount when locale changes
  const [widgetKey, setWidgetKey] = useState(0);

  useEffect(() => {
    const el = widgetRef.current;
    if (!el) return;

    function handleLogin() {
      navigate('/app');
    }

    el.addEventListener('spartan-login', handleLogin);
    return () => {
      el.removeEventListener('spartan-login', handleLogin);
    };
    // Re-run this effect when widgetKey changes (remount scenario)
  }, [widgetKey, navigate]);

  return (
    <spartan-login
      key={widgetKey}
      ref={widgetRef}
      domain="https://api.spartanauth.com"
      sector="your-sector-id"
      start-mode="password"
      locale={locale}
      redirect="">
    </spartan-login>
  );
}
```

---

### Vanilla JavaScript / Plain HTML

```html
<spartan-login
  id="login-widget"
  domain="https://api.spartanauth.com"
  sector="your-sector-id"
  start-mode="password"
  locale="en"
  redirect="">
</spartan-login>

<script type="module">
  import '@masonitestudios/spartanauth-widgets';

  document.getElementById('login-widget').addEventListener('spartan-login', () => {
    window.location.href = '/app';
  });
</script>
```

---

## Backend: Verifying the JWT (Token Introspection)

The widget issues a JWT signed by SpartanAuth. Your backend verifies tokens by calling SpartanAuth's introspection endpoint. **Do not decode and trust the JWT on the client side for authorization decisions** — always verify server-side.

### Go Backend Example (gRPC interceptor)

This is the canonical pattern for a Go service that uses grpc-gateway and protects most endpoints with SpartanAuth-issued tokens:

```go
// IntrospectResponse represents the JSON response from SpartanAuth.
// Note: SpartanAuth uses protobuf JSON encoding, which serialises int64 (exp, iat)
// as quoted strings to avoid JavaScript precision loss.
// The ",string" tag tells encoding/json to decode those quoted values.
type IntrospectResponse struct {
    Sub      string `json:"sub"`
    Username string `json:"username"`
    SectorID string `json:"sectorID"`
    IsAdmin  bool   `json:"isAdmin"`
    Exp      int64  `json:"exp,string"`
    Iat      int64  `json:"iat,string"`
}

// introspectToken calls SpartanAuth to validate a bearer token.
// Returns nil (not an error) when the token is rejected — only returns an
// error for network or decode failures.
func introspectToken(ctx context.Context, token string) (*IntrospectResponse, error) {
    reqBody := `{"token":"` + token + `"}`
    req, err := http.NewRequestWithContext(
        ctx, http.MethodPost,
        "https://api.spartanauth.com/api/v1/introspect",
        strings.NewReader(reqBody),
    )
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/json")

    client := &http.Client{Timeout: 5 * time.Second}
    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, nil // token rejected
    }

    var result IntrospectResponse
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return nil, fmt.Errorf("failed to decode introspection response: %w", err)
    }
    return &result, nil
}
```

### Wiring Introspection Into a gRPC Interceptor

```go
// publicMethods lists gRPC methods that do not require authentication.
publicMethods := map[string]bool{
    pb.MyService_SomePublicMethod_FullMethodName: true,
}

grpcServer := grpc.NewServer(
    grpc.ChainUnaryInterceptor(
        func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
            if publicMethods[info.FullMethod] {
                return handler(ctx, req)
            }

            md, ok := metadata.FromIncomingContext(ctx)
            if !ok {
                return nil, status.Errorf(codes.Unauthenticated, "missing metadata")
            }

            authValues := md.Get("authorization")
            if len(authValues) == 0 {
                return nil, status.Errorf(codes.Unauthenticated, "missing authorization header")
            }

            token := strings.TrimPrefix(authValues[0], "Bearer ")
            identity, err := introspectToken(ctx, token)
            if err != nil {
                return nil, status.Errorf(codes.Internal, "authentication error")
            }
            if identity == nil {
                return nil, status.Errorf(codes.Unauthenticated, "invalid token")
            }

            // Store identity in context for handler use
            ctx = context.WithValue(ctx, ctxSub, identity.Sub)
            ctx = context.WithValue(ctx, ctxSectorID, identity.SectorID)
            ctx = context.WithValue(ctx, ctxIsAdmin, identity.IsAdmin)

            return handler(ctx, req)
        },
    ),
)
```

The bearer token is forwarded automatically from the HTTP gateway to gRPC via grpc-gateway metadata forwarding when the client sends an `Authorization: Bearer <token>` header.

### Sending the Token From the Frontend

After the `spartan-login` event fires, read the token from `localStorage` and include it in API calls:

```ts
// Example: fetch wrapper that always sends the SpartanAuth token
async function apiFetch(path: string, options: RequestInit = {}) {
  const token = localStorage.getItem('spartan-token');
  return fetch(path, {
    ...options,
    headers: {
      ...options.headers,
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      'Content-Type': 'application/json',
    },
  });
}
```

---

## Automatic 401 Handling: The Fetch Override Pattern

For applications where **every page requires authentication** (fully walled-off apps), you can override `window.fetch` to globally intercept 401 responses and redirect to the login page:

```ts
// App.vue (or main entry point) — only for fully-authenticated apps
const { fetch: originalFetch } = window;

window.fetch = async (...args) => {
  const response = await originalFetch(...args);
  if (!response.ok && response.status === 401) {
    // Clear any stored auth state
    localStorage.removeItem('spartan-token');
    if (!window.location.pathname.startsWith('/login')) {
      window.location.href = '/login';
    }
  }
  return response;
};
```

**⚠️ When NOT to use the fetch override:**

Do not use this pattern if your application has any pages or features accessible to unauthenticated users. In that case, a 401 on a public endpoint would incorrectly redirect the user to the login page. Instead, handle authentication failures individually at the call site or in a dedicated API layer, and only prompt for login when the user attempts a protected action.

---

## Reading JWT Claims Client-Side

The widget stores the raw JWT in `localStorage['spartan-token']`. You can decode it client-side (without verification — only for display purposes, not authorization):

```ts
function getTokenClaims(): { sub: string; email: string; sectorID: string } | null {
  const token = localStorage.getItem('spartan-token');
  if (!token) return null;
  try {
    // JWT is three base64url segments; the middle is the payload
    const payload = JSON.parse(atob(token.split('.')[1]));
    // SpartanAuth nests custom claims under "Claims"
    const claims = payload.Claims || payload;
    return {
      sub: claims.sub,
      email: claims.email,
      sectorID: claims.sectorID,
    };
  } catch {
    return null;
  }
}
```

**Note:** JWT claims are only safe to use client-side for display (e.g., showing the user's email). Always use the introspection endpoint server-side to make authorization decisions.

---

## Complete Vue 3 Example (Minimal)

This is the minimal integration for a single-sector application — no tenant lookup, no extra complexity:

```vue
<!-- src/views/LoginView.vue -->
<template>
  <div class="login-page">
    <spartan-login
      :key="widgetKey"
      ref="loginWidget"
      :domain="authDomain"
      :sector="sectorId"
      start-mode="password"
      :locale="locale"
      redirect="">
    </spartan-login>
  </div>
</template>

<script setup lang="ts">
import '@masonitestudios/spartanauth-widgets';
import { ref, watch, onBeforeUnmount } from 'vue';
import { useRouter } from 'vue-router';

const router = useRouter();
const authDomain = import.meta.env.VITE_SPARTANAUTH_DOMAIN || 'https://api.spartanauth.com';
const sectorId = import.meta.env.VITE_SPARTANAUTH_SECTOR;
const locale = ref(navigator.language.slice(0, 2) || 'en');
const widgetKey = ref(0);
const loginWidget = ref<HTMLElement | null>(null);

function onLogin() {
  router.push('/');
}

watch(loginWidget, (newEl, oldEl) => {
  oldEl?.removeEventListener('spartan-login', onLogin);
  newEl?.addEventListener('spartan-login', onLogin);
});

onBeforeUnmount(() => {
  loginWidget.value?.removeEventListener('spartan-login', onLogin);
});

watch(locale, () => { widgetKey.value++; });
</script>
```

---

## Common Mistakes

| Mistake | Fix |
|---|---|
| Attaching the event listener in a `setTimeout` or without a ref | Use `watch(ref, ...)` to attach after mount |
| Forgetting to remove the event listener on unmount | Always pair with `onBeforeUnmount` or a `useEffect` cleanup |
| Not incrementing the key when locale changes | `watch(locale, () => { key.value++ })` |
| Using the fetch override in a partially-public app | Only override fetch for fully-walled apps |
| Trusting JWT claims for server-side authorization | Always call the introspection endpoint on the backend |
| Implementing sector lookup when you only have one sector | Just hardcode your sector ID from the dashboard |
