Skip to content

feat(desktop): add AniList account linking via OAuth implicit grant#139

Open
Shironex wants to merge 1 commit into
masterfrom
feat/anilist-oauth
Open

feat(desktop): add AniList account linking via OAuth implicit grant#139
Shironex wants to merge 1 commit into
masterfrom
feat/anilist-oauth

Conversation

@Shironex
Copy link
Copy Markdown
Owner

What

Foundation for the AniList sync chain (the highest-leverage post-v1 item in the gap analysis): link an AniList account, persist the session securely, and expose an authenticated GraphQL path. No manual token handling, no client secret.

Built via a contract-first parallel workflow — a frozen cross-package contract, then package-isolated desktop + web slices, then a cross-package typecheck/test/lint gate and an adversarial security review (4 findings, all fixed before this PR).

How

Auth flow (apps/desktop/src/main/auth/)

  • AniList implicit grant in a hardened popup BrowserWindow: isolated non-persistent session partition, contextIsolation:true / sandbox:true / nodeIntegration:false, window.open denied, navigation confined to AniList origins, redirect intercepted on both will-redirect and will-navigate with a canonical origin+pathname match (not a prefix test), token read from the URL fragment — the redirect page never loads.
  • The access token is never logged and never crosses IPC.
  • safeStorage-backed token store with an explicit, flagged plaintext fallback when OS encryption is unavailable; expiry-aware getToken/getStatus; missing/garbled expires_in falls back to AniList's documented ~1-year TTL.

Wiring

  • AniListTokenPort (abstract class, provided from main) injected into AniListClient; new getViewer() runs authenticated queries while the existing unauthenticated path is unchanged.
  • IPC connect/disconnect/status (renderer only ever receives AniListAuthStatus), preload bridge, and a @Global() AniListAuthModule whose wiring is asserted by a NestJS boot test.

Renderer

  • useAniListAuthStore (Zustand, guards missing bridge for the web build) + AccountsSection settings panel (pl/en) with connected / disconnected / loading / error states.

Verification

  • Cross-package pnpm typecheck: clean
  • pnpm lint: 0 errors (2 pre-existing warnings in unrelated test files)
  • Desktop jest (incl. 23 auth tests) + web vitest (incl. 12 new): green
  • Locale parity: passes

⚠️ Maintainer action required before the connect flow works

Register a developer app at anilist.co/settings/developer with redirect URI https://shiroani.app/oauth/anilist, then set the public client id via ANILIST_CLIENT_ID (main) / VITE_ANILIST_CLIENT_ID (web). Until set, Połącz z AniList fails with a clear "not configured" error — the rest of the app is unaffected.

Follow-ups (not in this PR)

Two-way list/progress/score sync, auto-scrobble write-back on detected episode completion, and auto cover/ID resolution — all of which this OAuth foundation unlocks.

Foundation for the AniList sync chain: link an AniList account, persist the
session securely, and expose an authenticated GraphQL path — no manual token
handling and no client secret.

- Implicit grant in a hardened popup BrowserWindow: isolated non-persistent
  session partition, contextIsolation/sandbox on, nodeIntegration off,
  window.open denied, navigation confined to AniList origins, redirect
  intercepted on will-redirect AND will-navigate with a canonical
  origin+pathname match, token read from the URL fragment (redirect page
  never loads). Token is never logged and never crosses IPC.
- safeStorage-backed token store with an explicit, flagged plaintext fallback
  when OS encryption is unavailable; expiry-aware getToken/getStatus.
- AniListTokenPort (provided from main) injected into AniListClient; new
  getViewer() runs authenticated queries while the unauthenticated path is
  unchanged.
- IPC connect/disconnect/status (renderer only ever sees AniListAuthStatus),
  preload bridge, and a @global() AniListAuthModule wiring verified by a Nest
  boot test.
- Renderer: useAniListAuthStore + AccountsSection settings panel (pl/en),
  connected/disconnected/loading/error states.

Requires a maintainer-supplied public client_id (ANILIST_CLIENT_ID /
VITE_ANILIST_CLIENT_ID); redirect URI https://shiroani.app/oauth/anilist.
The feature is inert with a clear "not configured" error until set.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

Warning

Review limit reached

@Shironex, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 28 minutes and 15 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 745dba67-8d8d-4334-a4df-161ebacdf20d

📥 Commits

Reviewing files that changed from the base of the PR and between 4903b0d and fd33721.

📒 Files selected for processing (38)
  • apps/desktop/src/__mocks__/electron.ts
  • apps/desktop/src/main/auth/__tests__/anilist-oauth.test.ts
  • apps/desktop/src/main/auth/__tests__/anilist-token-store.test.ts
  • apps/desktop/src/main/auth/anilist-oauth.ts
  • apps/desktop/src/main/auth/anilist-token-store.ts
  • apps/desktop/src/main/auth/anilist-token.adapter.ts
  • apps/desktop/src/main/index.ts
  • apps/desktop/src/main/ipc/__tests__/anilist-auth.test.ts
  • apps/desktop/src/main/ipc/anilist-auth.ts
  • apps/desktop/src/main/ipc/index.ts
  • apps/desktop/src/main/ipc/register.ts
  • apps/desktop/src/main/preload/_shared.ts
  • apps/desktop/src/main/preload/anilist-auth.ts
  • apps/desktop/src/main/preload/index.ts
  • apps/desktop/src/modules/anime/__tests__/anilist-auth-wiring.boot.test.ts
  • apps/desktop/src/modules/anime/__tests__/anilist-client.test.ts
  • apps/desktop/src/modules/anime/anilist-auth.module.ts
  • apps/desktop/src/modules/anime/anilist-client.ts
  • apps/desktop/src/modules/anime/anilist-token.port.ts
  • apps/desktop/src/modules/anime/anime.module.ts
  • apps/desktop/src/modules/anime/index.ts
  • apps/desktop/src/modules/app.module.ts
  • apps/web/src/components/settings/AccountsSection.tsx
  • apps/web/src/components/settings/SettingsView.tsx
  • apps/web/src/components/settings/__tests__/AccountsSection.test.tsx
  • apps/web/src/lib/i18n.ts
  • apps/web/src/locales/en/accounts.json
  • apps/web/src/locales/en/settings.json
  • apps/web/src/locales/pl/accounts.json
  • apps/web/src/locales/pl/settings.json
  • apps/web/src/stores/__tests__/useAniListAuthStore.test.ts
  • apps/web/src/stores/useAniListAuthStore.ts
  • apps/web/src/types/i18next.d.ts
  • packages/shared/src/constants/anilist.ts
  • packages/shared/src/constants/index.ts
  • packages/shared/src/index.ts
  • packages/shared/src/types/anilist-auth.ts
  • packages/shared/src/types/electron-api.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/anilist-oauth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added i18n Internationalization and localization area:electron Main process, IPC, window management area:frontend React UI, stores, components labels May 30, 2026
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces AniList OAuth (implicit grant) authentication, allowing users to securely connect their AniList accounts. Key changes include implementing an isolated Electron browser window for the OAuth flow, securely persisting the access token using Electron's 'safeStorage', and exposing the connection status to the renderer via IPC. The AniListClient has been updated to support authenticated requests and fetch the user's profile, accompanied by a new 'Accounts' settings section in the frontend. The review feedback suggests several robustness and security enhancements: explicitly clearing the OAuth session partition before starting the flow, extracting detailed error messages from the redirect URL when authorization fails, validating persisted session fields during retrieval, and adding defensive checks when parsing the GraphQL viewer response.

clientId: string
): Promise<{ accessToken: string; expiresIn: number }> {
return new Promise((resolve, reject) => {
const oauthSession = session.fromPartition(OAUTH_PARTITION, { cache: false });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

To prevent session reuse or credential leakage across multiple authorization attempts (e.g., if a user wants to switch accounts or retry after a failure), it is highly recommended to explicitly clear any leftover session data (cookies, localStorage, etc.) from the partition before initiating the OAuth flow.

Suggested change
const oauthSession = session.fromPartition(OAUTH_PARTITION, { cache: false });
const oauthSession = session.fromPartition(OAUTH_PARTITION, { cache: false });
void oauthSession.clearStorageData();

Comment on lines +175 to +180
const parsed = parseTokenFragment(navUrl);
if (parsed) {
finish(parsed);
} else {
fail('AniList redirect did not contain an access token');
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When the AniList authorization fails or is denied by the user, AniList redirects with error details in the URL fragment (e.g., #error=access_denied&error_description=...). Extracting and forwarding these error details provides a much more helpful and actionable error message to the user instead of a generic 'did not contain an access token' error.

        const parsed = parseTokenFragment(navUrl);
        if (parsed) {
          finish(parsed);
        } else {
          const hashIndex = navUrl.indexOf('#');
          const fragment = hashIndex !== -1 ? navUrl.slice(hashIndex + 1) : '';
          const params = new URLSearchParams(fragment);
          const error = params.get('error');
          const errorDesc = params.get('error_description');
          if (error) {
            fail('AniList authorization failed: ' + (errorDesc || error));
          } else {
            fail('AniList redirect did not contain an access token');
          }
        }

Comment on lines +35 to +41
function readSession(): PersistedSession | null {
const raw = store.get(SESSION_KEY) as PersistedSession | undefined;
if (!raw || typeof raw.token !== 'string' || typeof raw.expiresAt !== 'number') {
return null;
}
return raw;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential runtime errors or UI inconsistencies, validate that expiresAt is a valid finite number (not NaN) and that the viewer object is present and valid before returning the session. If viewer is missing, the UI might incorrectly treat the state as disconnected even though connected is true.

function readSession(): PersistedSession | null {
  const raw = store.get(SESSION_KEY) as PersistedSession | undefined;
  if (
    !raw ||
    typeof raw.token !== 'string' ||
    typeof raw.expiresAt !== 'number' ||
    !Number.isFinite(raw.expiresAt) ||
    !raw.viewer ||
    typeof raw.viewer !== 'object'
  ) {
    return null;
  }
  return raw;
}

Comment on lines +76 to +85
async getViewer(): Promise<AniListViewer> {
const data = await this.query<ViewerResponse>(VIEWER_QUERY);
const viewer = data.Viewer;
return {
id: viewer.id,
name: viewer.name,
avatar: viewer.avatar?.large,
bannerImage: viewer.bannerImage,
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Apply defensive programming by verifying that the viewer object exists in the GraphQL response before accessing its properties. This prevents potential TypeError crashes if the API returns a successful response structure but with a null Viewer field.

  async getViewer(): Promise<AniListViewer> {
    const data = await this.query<ViewerResponse>(VIEWER_QUERY);
    const viewer = data?.Viewer;
    if (!viewer) {
      throw new Error('AniList API returned no viewer data');
    }
    return {
      id: viewer.id,
      name: viewer.name,
      avatar: viewer.avatar?.large,
      bannerImage: viewer.bannerImage,
    };
  }

@Shironex Shironex self-assigned this May 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:electron Main process, IPC, window management area:frontend React UI, stores, components i18n Internationalization and localization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant