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
2 changes: 1 addition & 1 deletion packages/owletto
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { initWorkspaceProvider } from '../../../workspace';
import { cleanupTestDatabase } from '../../setup/test-db';
import {
addUserToOrganization,
createTestDeviceCode,
createTestOAuthClient,
createTestOrganization,
createTestSession,
createTestUser,
Expand Down Expand Up @@ -163,3 +165,45 @@ describe('user_claimed flow — full loop', () => {
expect((tokens.scope ?? '').split(' ')).toContain('mcp:read');
});
});

describe('GET /oauth/device/info — informed consent (who + scopes)', () => {
it('returns the requesting client name + scopes for a pending code (authed)', async () => {
const app = buildApp();
const user = await createTestUser({ name: 'Info User' });
const session = await createTestSession(user.id);
const client = await createTestOAuthClient({
client_name: 'Acme Agent',
grant_types: [DEVICE_GRANT, 'refresh_token'],
});
const device = await createTestDeviceCode(client.client_id, {
scope: 'mcp:read mcp:write',
});

const res = await call(app, 'GET', `/oauth/device/info?user_code=${device.userCode}`, {
headers: { Cookie: session.cookieHeader },
});
expect(res.status).toBe(200);
const body = (await res.json()) as { client_name: string; scopes: string[] };
expect(body.client_name).toBe('Acme Agent');
expect(body.scopes).toContain('mcp:read');
expect(body.scopes).toContain('mcp:write');
});

it('requires authentication', async () => {
const app = buildApp();
const client = await createTestOAuthClient({ grant_types: [DEVICE_GRANT, 'refresh_token'] });
const device = await createTestDeviceCode(client.client_id);
const res = await call(app, 'GET', `/oauth/device/info?user_code=${device.userCode}`);
expect(res.status).toBe(401);
});

it('400s for an unknown user_code', async () => {
const app = buildApp();
const user = await createTestUser({ name: 'Info User 2' });
const session = await createTestSession(user.id);
const res = await call(app, 'GET', '/oauth/device/info?user_code=NOPE-NOPE', {
headers: { Cookie: session.cookieHeader },
});
expect(res.status).toBe(400);
});
});
21 changes: 19 additions & 2 deletions packages/server/src/auth/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
invitationSubject,
} from "../email/templates/invitation";
import {
authorizeAppSubject,
MagicLinkEmail,
magicLinkSubject,
} from "../email/templates/magic-link";
Expand Down Expand Up @@ -531,12 +532,28 @@ export async function createAuth(env: Env, request?: Request) {
"Magic-link email delivery is not configured (RESEND_API_KEY missing). Check server logs for the generated link.",
);
}
// A magic link whose callbackURL targets the device consent page
// is an agent/app authorization request (POST /oauth/device/email),
// not a routine login — frame the email accordingly so the user
// doesn't grant third-party access thinking they're just signing in.
let isAuthorizeRequest = false;
try {
const callbackUrl = new URL(url).searchParams.get("callbackURL") ?? "";
isAuthorizeRequest = decodeURIComponent(callbackUrl).includes("/oauth/device");
} catch {
isAuthorizeRequest = false;
}
await sendTransactionalEmail({
env,
to: email,
category: "auth",
subject: magicLinkSubject,
react: <MagicLinkEmail url={url} />,
subject: isAuthorizeRequest ? authorizeAppSubject : magicLinkSubject,
react: (
<MagicLinkEmail
url={url}
mode={isAuthorizeRequest ? "authorize" : "sign-in"}
/>
),
});
},
expiresIn: 60 * 15, // 15 minutes
Expand Down
26 changes: 26 additions & 0 deletions packages/server/src/auth/oauth/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,32 @@ oauthRoutes.post('/oauth/device/email', async (c) => {
// GET /oauth/device is served by the SPA fallback (packages/owletto/src/app/oauth/device.tsx).
// No API route needed — the web app and API share the same origin.

/**
* GET /oauth/device/info?user_code=...
* Returns the requesting client + scopes for a pending device code so the
* consent page can show the user WHO is asking and WHAT they will get — rather
* than approving a blind code. Auth-gated: only the (authenticated) user on the
* consent page needs this, and it avoids leaking client/scope by user_code.
*/
oauthRoutes.get('/oauth/device/info', requireAuth, async (c) => {
const userCode = c.req.query('user_code')?.trim();
if (!userCode) {
return c.json(createOAuthError('invalid_request', 'user_code is required'), 400);
}
const provider = getProvider(c);
const deviceCode = await provider.getDeviceCodeByUserCode(userCode);
if (!deviceCode) {
return c.json(createOAuthError('invalid_grant', 'Invalid or expired user code'), 400);
}
const client = await provider.clientsStore.getClient(deviceCode.client_id);
return c.json({
client_name: client?.client_name ?? null,
client_id: deviceCode.client_id,
scopes: (deviceCode.scope ?? '').split(' ').filter(Boolean),
resource: deviceCode.resource,
});
});

/**
* POST /oauth/device/approve
* Device Code Approval Endpoint
Expand Down
21 changes: 20 additions & 1 deletion packages/server/src/email/templates/magic-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,27 @@ import { BrandedEmail } from './BrandedEmail';

export interface MagicLinkProps {
url: string;
/**
* 'sign-in' (default) is a normal login link. 'authorize' is used when an
* application/agent is requesting access on the user's behalf — the copy must
* make clear this is an authorization request, not a routine sign-in, so the
* user doesn't grant third-party access thinking they're just logging in.
*/
mode?: 'sign-in' | 'authorize';
}

export function MagicLinkEmail({ url }: MagicLinkProps) {
export function MagicLinkEmail({ url, mode = 'sign-in' }: MagicLinkProps) {
if (mode === 'authorize') {
return (
<BrandedEmail
preview="An application is requesting access to your Lobu account"
heading="Authorize access to Lobu"
intro="An application is requesting access to your Lobu account. Click below to review the request — you'll see what it can access before you approve. This link expires in 15 minutes."
cta={{ href: url, label: 'Review request' }}
footerNote="If you didn't request this, you can safely ignore this email — no access is granted unless you approve."
/>
);
}
return (
<BrandedEmail
preview="Your sign-in link for Lobu"
Expand All @@ -17,3 +35,4 @@ export function MagicLinkEmail({ url }: MagicLinkProps) {
}

export const magicLinkSubject = 'Your Lobu sign-in link';
export const authorizeAppSubject = 'Authorize access to your Lobu account';
Loading