• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

karanshukla / navyfragen-app / 28455186937

30 Jun 2026 03:15PM UTC coverage: 96.163% (-1.4%) from 97.563%
28455186937

push

github

web-flow
Merge pull request #191 from karanshukla/dev-push-notifications

Dev push notifications

2103 of 2293 branches covered (91.71%)

Branch coverage included in aggregate %.

880 of 988 new or added lines in 20 files covered. (89.07%)

4 existing lines in 4 files now uncovered.

7923 of 8133 relevant lines covered (97.42%)

6.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.52
/server/src/controllers/auth-controller.ts
1
/* v8 ignore start */
1✔
2
import { isValidDid, isValidHandle } from "@atproto/syntax";
1✔
3
import { Request, Response } from "express";
1✔
4

1✔
5
import {
1✔
6
  findAccount,
1✔
7
  getAccounts,
1✔
8
  removeAccount,
1✔
9
  toAccountEntry,
1✔
10
  upsertAccount,
1✔
11
} from "../auth/session";
1✔
12
import { env } from "../lib/env";
1✔
13
import { pdsRegion } from "../lib/pds-region";
1✔
14
import { AuthService } from "../services/auth-service";
1✔
15

1✔
16
import type { AppContext } from "../index";
1✔
17

1✔
18
export class AuthController {
1✔
19
  private service: AuthService;
1✔
20
  constructor(private ctx: AppContext) {
1✔
21
    this.service = new AuthService(ctx);
1✔
22
  }
1✔
23
  /* v8 ignore stop */
1✔
24

1✔
25
  async login(req: Request, res: Response) {
1✔
26
    const handle = req.body?.handle;
6✔
27
    if (typeof handle !== "string" || !isValidHandle(handle)) {
6✔
28
      return res.status(400).json({ error: "invalid handle" });
3✔
29
    }
3✔
30
    try {
3✔
31
      this.ctx.logger.info({ handle }, "Starting OAuth authorize");
3✔
32
      const redirectUrl = await this.service.getOAuthRedirectUrl(handle);
3✔
33
      this.ctx.logger.info({ redirectUrl }, "OAuth authorize succeeded");
6✔
34
      return res.json({ redirectUrl });
1✔
35
    } catch (err: any) {
6✔
36
      return res.status(500).json({ error: err.message || "couldn't initiate login" });
2✔
37
    }
2✔
38
  }
6✔
39

1✔
40
  async logout(req: Request, res: Response) {
1✔
41
    if (!req.session?.did) {
4✔
42
      return res.status(400).json({ error: "Not logged in" });
1✔
43
    }
1✔
44
    const did = req.session.did;
4✔
45
    try {
3✔
46
      await this.service.revokeSession(did);
3✔
47
      this.ctx.logger.info({ did }, "OAuth session revoked");
4✔
48
    } catch (err) {
4✔
49
      this.ctx.logger.error({ err, did }, "Failed to revoke OAuth session");
1✔
50
      return res.status(500).json({ error: "Failed to log out" });
1✔
51
    }
1✔
52
    // Drop the signed-out account from the remembered list.
2✔
53
    removeAccount(req.session, did);
2✔
54
    // If other accounts remain, switch to the first rather than ending the
2✔
55
    // session entirely. Single-account users get the original full-logout behaviour.
2✔
56
    const remaining = getAccounts(req.session);
2✔
57
    if (remaining.length > 0) {
4✔
58
      req.session.did = remaining[0].did;
1✔
59
      this.ctx.logger.info({ did: req.session.did }, "Switched active account after logout");
1✔
60
      return res.status(200).json({ message: "Logged out, switched account", switched: true });
1✔
61
    }
1✔
62
    req.session = null;
1✔
63
    res.clearCookie("nf-region", { path: "/" });
1✔
64
    return res.status(200).json({ message: "Logged out successfully" });
1✔
65
  }
4✔
66

1✔
67
  async session(req: Request, res: Response) {
1✔
68
    if (!req.session?.did) {
5✔
69
      req.session = null;
1✔
70
      this.ctx.logger.debug("No session cookie, returning not logged in");
1✔
71
      return res.json({ isLoggedIn: false, profile: null, did: null });
1✔
72
    }
1✔
73
    try {
5✔
74
      let did = req.session.did;
4✔
75
      let profile = await this.service.checkSession(did);
4✔
76

5✔
77
      // Active account's token is no longer valid — drop it and try to fall
5✔
78
      // back to another remembered account before giving up entirely.
5✔
79
      if (!profile) {
5✔
80
        removeAccount(req.session, did);
2✔
81
        const fallback = getAccounts(req.session)[0];
2✔
82
        if (fallback) {
2✔
83
          did = fallback.did;
1✔
84
          req.session.did = did;
1✔
85
          profile = await this.service.checkSession(did);
1✔
86
          if (profile) {
1✔
87
            upsertAccount(req.session, toAccountEntry(profile));
1✔
88
          } else {
1!
NEW
89
            removeAccount(req.session, did);
×
NEW
90
          }
×
91
        }
1✔
92
      } else {
5✔
93
        // Refresh the active account's cached entry (handle/avatar may have changed).
1✔
94
        upsertAccount(req.session, toAccountEntry(profile));
1✔
95
      }
1✔
96

5✔
97
      if (!profile) {
5✔
98
        req.session = null;
1✔
99
        return res.json({ isLoggedIn: false, profile: null, did: null });
1✔
100
      }
1✔
101

5✔
102
      return res.json({
5✔
103
        isLoggedIn: true,
2✔
104
        profile,
2✔
105
        did: req.session.did,
2✔
106
        accounts: getAccounts(req.session),
2✔
107
      });
2✔
108
    } catch (err) {
5✔
109
      req.session = null;
1✔
110
      this.ctx.logger.error({ err }, "Error fetching profile");
1✔
111
      return res.json({ isLoggedIn: false, profile: null, did: null });
1✔
112
    }
1✔
113
  }
5✔
114

1✔
115
  clientMetadata(req: Request, res: Response) {
1✔
116
    return res.json(this.ctx.oauthClient.clientMetadata);
1✔
117
  }
1✔
118

1✔
119
  async oauthCallback(req: Request, res: Response) {
1✔
120
    const params = new URLSearchParams(req.originalUrl.split("?")[1]);
6✔
121
    try {
6✔
122
      const callbackResult = await this.ctx.oauthClient.callback(params);
6✔
123
      const did = callbackResult.session.did;
6✔
124
      try {
3✔
125
        await this.service.createOrConfirmUserProfile(did);
3✔
126
        this.ctx.logger.info({ did }, "User profile entry created or confirmed.");
6✔
127
      } catch (dbErr) {
6✔
128
        this.ctx.logger.error(
1✔
129
          { err: dbErr, did },
1✔
130
          "Failed to create or confirm user profile entry."
1✔
131
        );
1✔
132
      }
1✔
133
      req.session = req.session ?? {};
6!
134
      // Make the newly authenticated account active. Existing remembered
6✔
135
      // accounts are preserved so this doubles as "add account".
6✔
136
      req.session.did = did;
6✔
137
      this.ctx.logger.info({ did }, "OAuth callback successful, session created");
6✔
138
      let token: string;
6✔
139
      try {
6✔
140
        token = this.service.encryptDid(did);
6✔
141
      } catch (e: any) {
6✔
142
        this.ctx.logger.error("OAUTH_TOKEN_SECRET is not set");
1✔
143
        return res.redirect(`${env.CLIENT_URL}/login?error=server_config`);
1✔
144
      }
1✔
145
      return res.redirect(`${env.CLIENT_URL}/oauth_callback?oauth_token=${token}`);
6✔
146
    } catch (err) {
6✔
147
      this.ctx.logger.error(
3✔
148
        {
3✔
149
          err: err instanceof Error ? err.stack || err.message : err,
3✔
150
          params: Object.fromEntries(params.entries()),
3✔
151
        },
3✔
152
        "oauth callback failed"
3✔
153
      );
3✔
154
      return res.redirect(`${env.CLIENT_URL}/login?error=oauth_failed`);
3✔
155
    }
3✔
156
  }
6✔
157

1✔
158
  async oauthConsume(req: Request, res: Response) {
1✔
159
    const { oauth_token } = req.body;
8✔
160
    if (!oauth_token) {
8✔
161
      return res.status(400).json({ error: "Missing oauth_token" });
1✔
162
    }
1✔
163
    let did: string;
8✔
164
    try {
7✔
165
      did = this.service.decryptDid(oauth_token);
7✔
166
    } catch (e: any) {
8✔
167
      this.ctx.logger.error("OAUTH_TOKEN_SECRET is not set");
1✔
168
      return res.status(500).json({ error: "Server misconfiguration" });
1✔
169
    }
1✔
170
    try {
8✔
171
      const user = await this.service.findUserByDid(did);
6✔
172
      if (!user) {
8✔
173
        return res.status(404).json({ error: "User not found" });
1✔
174
      }
1✔
175
      req.session = req.session ?? {};
8!
176
      // Preserve any previously remembered accounts (add-account flow).
8✔
177
      req.session.did = did;
8✔
178
      this.ctx.logger.info({ did }, "Session set from oauth_token");
8✔
179

8✔
180
      // Set a routing hint cookie so Caddy can forward subsequent requests to
8✔
181
      // the backend closest to the user's PDS (minimises PDS↔backend latency).
8✔
182
      // Non-fatal: missing cookie means Caddy falls back to the EU backend.
8✔
183
      try {
8✔
184
        const atData = await this.ctx.idResolver.did.resolveAtprotoData(did);
8✔
185
        const region = pdsRegion(atData.pds);
8✔
186
        res.cookie("nf-region", region, {
3✔
187
          maxAge: 14 * 24 * 60 * 60 * 1000,
3✔
188
          httpOnly: false,
3✔
189
          sameSite: "lax",
3✔
190
          path: "/",
3✔
191
        });
3✔
192
        this.ctx.logger.info({ did, pds: atData.pds, region }, "PDS region resolved");
3✔
193
      } catch (regionErr) {
8✔
194
        this.ctx.logger.warn(
1✔
195
          { err: regionErr, did },
1✔
196
          "PDS region resolution failed, skipping nf-region cookie"
1✔
197
        );
1✔
198
      }
1✔
199

8✔
200
      return res.json({ success: true });
8✔
201
    } catch (err) {
8✔
202
      this.ctx.logger.error({ err }, "Failed to consume oauth_token");
1✔
203
      return res.status(400).json({ error: "Invalid or expired token" });
1✔
204
    }
1✔
205
  }
8✔
206

1✔
207
  /**
1✔
208
   * Switches the active account to a previously remembered DID.
1✔
209
   *
1✔
210
   * The OAuth tokens for every remembered account already live in the DB, so
1✔
211
   * switching is a cheap pointer flip — no OAuth round-trip required. We still
1✔
212
   * verify the target account's session is restorable; if it has expired the
1✔
213
   * account is dropped from the remembered list.
1✔
214
   *
1✔
215
   * SECURITY: the only DIDs that can be switched to are those already present
1✔
216
   * in the signed cookie-session's `accounts` array. That array is populated
1✔
217
   * exclusively server-side after a successful Bluesky OAuth callback, and the
1✔
218
   * cookie is HMAC-signed with COOKIE_SECRET — so it cannot be forged or
1✔
219
   * extended client-side. The `findAccount` check is the authoritative gate;
1✔
220
   * DID format validation below is defense-in-depth only.
1✔
221
   */
1✔
222
  async switchAccount(req: Request, res: Response) {
1✔
223
    const did = req.body?.did;
6✔
224
    if (typeof did !== "string" || !did) {
6✔
225
      return res.status(400).json({ error: "did is required" });
1✔
226
    }
1✔
227
    // Defense in depth: reject malformed DIDs before touching the DB, even
6✔
228
    // though findAccount() below would also reject unknown DIDs.
6✔
229
    if (!isValidDid(did)) {
6✔
230
      return res.status(400).json({ error: "Invalid DID format" });
1✔
231
    }
1✔
232
    // findAccount returns undefined for a null session, so reaching here proves
6✔
233
    // the session is present and the DID is genuinely remembered. Capturing the
6✔
234
    // narrowed reference avoids repeated null-checks below.
6✔
235
    const session = req.session;
6✔
236
    if (!session || !findAccount(session, did)) {
6✔
237
      // A request to switch to a DID not in the session is suspicious — it
1✔
238
      // either means a tampered cookie (should be impossible) or a probing
1✔
239
      // attempt. Log it with the caller's active DID for auditing.
1✔
240
      this.ctx.logger.warn(
1✔
241
        { requestedDid: did, activeDid: session?.did },
1✔
242
        "Account switch denied: DID not in session"
1✔
243
      );
1✔
244
      return res.status(403).json({ error: "Account not found in session" });
1✔
245
    }
1✔
246
    try {
6✔
247
      const profile = await this.service.checkSession(did);
3✔
248
      if (!profile) {
6✔
249
        // Token for that account is no longer valid — stop remembering it.
1✔
250
        removeAccount(session, did);
1✔
251
        this.ctx.logger.info({ did }, "Switch failed, account session expired");
1✔
252
        return res.status(401).json({ error: "That account's session has expired" });
1✔
253
      }
1✔
254
      session.did = did;
1✔
255
      upsertAccount(session, toAccountEntry(profile));
1✔
256
      this.ctx.logger.info({ did }, "Switched active account");
1✔
257
      return res.json({ success: true, did });
1✔
258
    } catch (err) {
1✔
259
      this.ctx.logger.error({ err, did }, "Failed to switch account");
1✔
260
      return res.status(500).json({ error: "Failed to switch account" });
1✔
261
    }
1✔
262
  }
6✔
263
  /* v8 ignore next 1 */
1✔
264
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc