Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 64 additions & 17 deletions src/app/api/v1/nixamp/oauth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
/**
* GET /api/v1/nixamp/oauth/callback
*
* Where nixamp sends the browser back. This is the exact path registered with
* Where nixamp sends the browser back, to connect an account or to sign in
* as one ("Sign in with nixamp": no session here yet, the identity is the
* one nixamp vouches for, and the account with that email is theirs).
*
* This is the exact path registered with
* nixamp, which matches it byte for byte, so it is not something to rename
* lightly: the OAuth 2.1 rule is exact matching, with only a loopback port
* allowed to vary.
Expand All @@ -14,24 +18,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
import {
AUTH_COOKIE_MAX_AGE,
AUTH_COOKIE_NAME,
discover,
exchangeCodeForTokens,
fetchUserInfo,
getNixampOAuthConfig,
NIXAMP_OAUTH_STATE_COOKIE,
NixampSignInError,
safeRedirect,
sessionCookieValue,
sessionForNixampIdentity,
upsertNixampAccount,
} from '@/lib/nixamp';

const SETTINGS = '/settings?tab=connections';

function back(origin: string, params: Record<string, string>): NextResponse {
const url = new URL(SETTINGS, origin);
function back(origin: string, params: Record<string, string>, to = SETTINGS): NextResponse {
const url = new URL(to, origin);
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
const res = NextResponse.redirect(url);
res.cookies.delete(NIXAMP_OAUTH_STATE_COOKIE);
return res;
}

/** A refusal on the way in goes to the login page, which knows how to say it. */
function refuse(origin: string, reason: string, redirect: string): NextResponse {
return back(origin, { nixamp_error: reason, redirect }, '/login');
}

export async function GET(request: NextRequest): Promise<Response> {
// The public origin, from the configured redirect URI rather than
// request.url, which behind a proxy can be the internal bind address.
Expand All @@ -44,37 +59,56 @@ export async function GET(request: NextRequest): Promise<Response> {
return back(new URL(request.url).origin, { nixamp_error: 'server_misconfigured' });
}

const cookie = request.cookies.get(NIXAMP_OAUTH_STATE_COOKIE)?.value;
let kept: { state?: string; verifier?: string; redirect?: string; signin?: boolean } = {};
try {
kept = cookie ? (JSON.parse(cookie) as typeof kept) : {};
} catch {
kept = {};
}
const redirect = safeRedirect(kept.redirect);

// Signed in: connecting nixamp to this account. Signed out: this IS the
// sign-in, and the cookie set on the way out says so -- a callback that
// arrives with no session and no such cookie is not a flow we started.
const user = await getCurrentUser();
if (!user) return back(origin, { nixamp_error: 'not_authenticated' });
const signin = !user && kept.signin === true;
if (!user && !signin) return back(origin, { nixamp_error: 'not_authenticated' });
const fail = (reason: string): NextResponse => (signin ? refuse(origin, reason, redirect) : back(origin, { nixamp_error: reason }));

const { searchParams } = new URL(request.url);
const refused = searchParams.get('error');
if (refused) return back(origin, { nixamp_error: refused });
if (refused) return fail(refused);

const code = searchParams.get('code');
const state = searchParams.get('state');
if (!code || !state) return back(origin, { nixamp_error: 'missing_code_or_state' });
if (!code || !state) return fail('missing_code_or_state');

const cookie = request.cookies.get(NIXAMP_OAUTH_STATE_COOKIE)?.value;
let kept: { state?: string; verifier?: string } = {};
try {
kept = cookie ? (JSON.parse(cookie) as typeof kept) : {};
} catch {
kept = {};
}
// Both halves, and the state compared rather than merely present: a
// callback whose state we did not issue is somebody replaying a URL.
if (!kept.state || !kept.verifier || kept.state !== state) {
return back(origin, { nixamp_error: 'state_mismatch' });
return fail('state_mismatch');
}

try {
const metadata = await discover(config);
const tokens = await exchangeCodeForTokens(config, metadata, code, kept.verifier);
const who = await fetchUserInfo(metadata, tokens.access_token);

let userId = user?.id ?? '';
let session: { accessToken: string; refreshToken: string } | null = null;
if (signin) {
const minted = await sessionForNixampIdentity({
email: who.email ?? '',
sub: who.sub,
...(who.handle ? { handle: who.handle } : {}),
});
userId = minted.userId;
session = minted;
}

await upsertNixampAccount({
userId: user.id,
userId,
nixampSub: who.sub,
nixampSite: config.site,
// The handle, never the address: on nixamp the account email is the
Expand All @@ -88,9 +122,22 @@ export async function GET(request: NextRequest): Promise<Response> {
scopes: tokens.scope ? tokens.scope.split(' ').filter(Boolean) : [],
});

return back(origin, { nixamp: 'connected' });
// Home is wherever they were going: the party they were sent to, or the
// connections tab, which is what a plain Connect started from.
const res = back(origin, signin ? {} : { nixamp: 'connected' }, redirect);
if (session) {
res.cookies.set(AUTH_COOKIE_NAME, sessionCookieValue(session), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: AUTH_COOKIE_MAX_AGE,
});
}
return res;
} catch (err) {
console.error('[nixamp OAuth] callback failed:', err);
return back(origin, { nixamp_error: 'exchange_failed' });
if (err instanceof NixampSignInError) return fail(err.reason);
return fail('exchange_failed');
}
}
22 changes: 13 additions & 9 deletions src/app/api/v1/nixamp/oauth/start/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
/**
* GET /api/v1/nixamp/oauth/start
*
* Begins the OAuth 2.1 flow that connects a nixamp account to this one.
* Begins the OAuth 2.1 flow that connects a nixamp account to this one, or,
* for somebody signed out, signs them in here as their nixamp self.
* nixamp is the authorization server; we are the client.
*
* ?redirect=/watch-party?code=ABC123 where to land afterwards (this site only)
*
* Two secrets go out in one httpOnly cookie and neither ever reaches the
* browser's JavaScript: the CSRF state, which proves the callback belongs to
* a flow we started, and the PKCE verifier, which proves the code exchange is
Expand All @@ -22,17 +25,18 @@ import {
getNixampOAuthConfig,
NIXAMP_OAUTH_STATE_COOKIE,
NIXAMP_OAUTH_STATE_MAX_AGE_SECONDS,
safeRedirect,
} from '@/lib/nixamp';

export async function GET(request: NextRequest): Promise<Response> {
const origin = new URL(request.url).origin;
const url = new URL(request.url);
const origin = url.origin;
const redirect = safeRedirect(url.searchParams.get('redirect'));
const user = await getCurrentUser();
if (!user) {
// Connecting is an act of an account: there has to be one to connect TO.
const back = new URL('/login', origin);
back.searchParams.set('redirect', '/settings?tab=connections');
return NextResponse.redirect(back);
}
// Signed out, this is "Sign in with nixamp": the same round trip, and the
// callback makes the account here from the identity there. Signed in, it
// connects nixamp to the account that is already here.
const signin = !user;

let config;
try {
Expand All @@ -48,7 +52,7 @@ export async function GET(request: NextRequest): Promise<Response> {
const authUrl = buildAuthUrl(config, metadata, state, await codeChallenge(verifier));

const response = NextResponse.redirect(authUrl);
response.cookies.set(NIXAMP_OAUTH_STATE_COOKIE, JSON.stringify({ state, verifier }), {
response.cookies.set(NIXAMP_OAUTH_STATE_COOKIE, JSON.stringify({ state, verifier, redirect, signin }), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
Expand Down
54 changes: 54 additions & 0 deletions src/app/api/watch-party/_view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* What a party looks like to a client, and who counts as its host.
*
* One shape for every route, so the page can poll GET and get exactly what
* create, join and the playback update answered.
*/

import type { WatchParty } from '@/lib/watch-party';

export interface PartyView {
id: string;
code: string;
hostId: string;
hostName: string;
mediaUrl: string;
mediaTitle: string;
state: WatchParty['state'];
memberCount: number;
members: { id: string; name: string; isHost: boolean }[];
playback: WatchParty['playback'];
settings: WatchParty['settings'];
createdAt: string;
}

export function partyView(party: WatchParty): PartyView {
return {
id: party.id,
code: party.code,
hostId: party.hostId,
hostName: party.hostName,
mediaUrl: party.mediaUrl,
mediaTitle: party.mediaTitle,
state: party.state,
memberCount: party.members.length,
members: party.members.map((m) => ({ id: m.id, name: m.name, isHost: m.isHost })),
playback: party.playback,
settings: party.settings,
createdAt: party.createdAt.toISOString(),
};
}

/**
* Is this request the host's?
*
* A signed-in host is proven by the session: the party's hostId is their user
* id, and the cookie is the only thing that says so. A guest-hosted party has
* only its guest string, which is the best such a party can do -- and it is
* accepted only for guest hosts, so a member who saw a signed-in host's id in
* the member list cannot present it as their own.
*/
export function isHostOf(party: WatchParty, sessionUserId: string | undefined, claimedHostId: string | undefined): boolean {
if (sessionUserId && sessionUserId === party.hostId) return true;
return party.hostId.startsWith('guest_') && claimedHostId === party.hostId;
}
39 changes: 38 additions & 1 deletion src/app/api/watch-party/nixamp/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const nixamp = vi.hoisted(() => ({
endBridgedParty: vi.fn(),
getBridgedRoom: vi.fn(),
pushPlayback: vi.fn(),
readRoomChat: vi.fn(),
postRoomChat: vi.fn(),
NixampNotConnected: class NixampNotConnected extends Error {},
NixampConnectionLost: class NixampConnectionLost extends Error {},
}));
Expand Down Expand Up @@ -129,7 +131,7 @@ describe('POST', () => {
nixamp.bridgeParty.mockRejectedValue(new nixamp.NixampNotConnected());
const res = await POST(post({ code: 'ABC123' }));
expect(res.status).toBe(409);
expect((await res.json()).connect).toBe('/api/v1/nixamp/oauth/start');
expect((await res.json()).connect).toBe('/api/v1/nixamp/oauth/start?redirect=%2Fwatch-party%3Fcode%3DABC123');
});

it('says to connect again when the grant has been withdrawn', async () => {
Expand All @@ -150,3 +152,38 @@ describe('POST', () => {
expect(res.status).toBe(400);
});
});

describe('chat', () => {
it('reads the room to anybody, since the code was the invitation', async () => {
auth.getCurrentUser.mockResolvedValue(null);
nixamp.getBridgedRoom.mockResolvedValue(room);
nixamp.readRoomChat.mockResolvedValue([{ id: 'm1', authorName: 'chovy', body: 'hi', createdAt: '2026-09-17T00:00:00.000Z' }]);
const res = await GET(new NextRequest('https://bittorrented.test/api/watch-party/nixamp?code=ABC123&chat=1&after=2026-09-16T00:00:00.000Z'));
expect(res.status).toBe(200);
expect((await res.json()).messages).toHaveLength(1);
expect(nixamp.readRoomChat).toHaveBeenCalledWith(room, '2026-09-16T00:00:00.000Z');
});

it('lets any member post, as their own nixamp self', async () => {
auth.getCurrentUser.mockResolvedValue({ id: 'user-2', email: 'b@b.test' });
nixamp.getBridgedRoom.mockResolvedValue(room);
nixamp.postRoomChat.mockResolvedValue({ id: 'm2', authorName: 'bob', body: 'hello', createdAt: 'now' });
const res = await POST(post({ code: 'ABC123', action: 'chat', body: 'hello' }));
expect(res.status).toBe(200);
expect(nixamp.postRoomChat).toHaveBeenCalledWith('user-2', room, 'hello');
});

it('sends a member without nixamp to connect it, and back to this party', async () => {
auth.getCurrentUser.mockResolvedValue({ id: 'user-2', email: 'b@b.test' });
nixamp.getBridgedRoom.mockResolvedValue(room);
nixamp.postRoomChat.mockRejectedValue(new nixamp.NixampNotConnected());
const res = await POST(post({ code: 'ABC123', action: 'chat', body: 'hello' }));
expect(res.status).toBe(409);
expect((await res.json()).connect).toBe('/api/v1/nixamp/oauth/start?redirect=%2Fwatch-party%3Fcode%3DABC123');
});

it('has nothing to say in a party that is not on nixamp', async () => {
const res = await POST(post({ code: 'ABC123', action: 'chat', body: 'hello' }));
expect(res.status).toBe(409);
});
});
Loading
Loading