Skip to content

Commit acd8107

Browse files
committed
feat: implement RFC 8628 Device Authorization Grant for CLI login
1 parent 15bac38 commit acd8107

13 files changed

Lines changed: 397 additions & 173 deletions

File tree

apps/account/src/routes/auth.device.tsx

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { CheckCircle2, GalleryVerticalEnd } from 'lucide-react';
1010

1111
export const Route = createFileRoute('/auth/device')({
1212
validateSearch: (search: Record<string, unknown>) => ({
13-
code: (search.code as string) ?? '',
13+
user_code: (search.user_code as string) ?? (search.code as string) ?? '',
1414
}),
1515
component: DeviceAuthPage,
1616
});
@@ -32,12 +32,14 @@ function PageShell({ children }: { children: React.ReactNode }) {
3232
}
3333

3434
function DeviceAuthPage() {
35-
const { code } = Route.useSearch();
35+
const { user_code: code } = Route.useSearch();
3636
const { user, loading } = useSession();
3737
const navigate = useNavigate();
3838

3939
const [submitting, setSubmitting] = useState(false);
40+
const [denying, setDenying] = useState(false);
4041
const [approved, setApproved] = useState(false);
42+
const [denied, setDenied] = useState(false);
4143
const [error, setError] = useState('');
4244

4345
if (!code) {
@@ -46,7 +48,7 @@ function DeviceAuthPage() {
4648
<Card>
4749
<CardHeader className="text-center">
4850
<CardTitle>Invalid Request</CardTitle>
49-
<CardDescription>No device code provided. Please re-run the CLI command.</CardDescription>
51+
<CardDescription>No user code provided. Please re-run the CLI command.</CardDescription>
5052
</CardHeader>
5153
</Card>
5254
</PageShell>
@@ -66,7 +68,7 @@ function DeviceAuthPage() {
6668
}
6769

6870
if (!user) {
69-
return <Navigate to="/login" search={{ redirect: `/auth/device?code=${encodeURIComponent(code)}` }} />;
71+
return <Navigate to="/login" search={{ redirect: `/auth/device?user_code=${encodeURIComponent(code)}` }} />;
7072
}
7173

7274
if (approved) {
@@ -83,23 +85,33 @@ function DeviceAuthPage() {
8385
);
8486
}
8587

88+
if (denied) {
89+
return (
90+
<PageShell>
91+
<Card>
92+
<CardHeader className="text-center">
93+
<CardTitle>Request Denied</CardTitle>
94+
<CardDescription>The CLI login request has been denied.</CardDescription>
95+
</CardHeader>
96+
</Card>
97+
</PageShell>
98+
);
99+
}
100+
86101
const handleApprove = async () => {
87102
setError('');
88103
setSubmitting(true);
89104
try {
90-
const sessionRes = await fetch('/api/v1/auth/get-session', { credentials: 'include' });
91-
const sessionData = await sessionRes.json() as any;
92-
const token = sessionData?.session?.token;
93-
if (!token) throw new Error('No active session');
94-
95105
const res = await fetch('/api/v1/auth/device/approve', {
96106
method: 'POST',
97107
headers: { 'Content-Type': 'application/json' },
98108
credentials: 'include',
99-
body: JSON.stringify({ code, token }),
109+
body: JSON.stringify({ userCode: code }),
100110
});
101-
const data = await res.json() as any;
102-
if (!data.success) throw new Error(data.error?.message ?? 'Approval failed');
111+
if (!res.ok) {
112+
const data = await res.json().catch(() => ({}));
113+
throw new Error((data as any)?.message ?? (data as any)?.error?.message ?? 'Approval failed');
114+
}
103115

104116
setApproved(true);
105117
toast({ title: 'CLI login approved', description: 'The CLI has been authenticated.' });
@@ -110,6 +122,24 @@ function DeviceAuthPage() {
110122
}
111123
};
112124

125+
const handleDeny = async () => {
126+
setError('');
127+
setDenying(true);
128+
try {
129+
await fetch('/api/v1/auth/device/deny', {
130+
method: 'POST',
131+
headers: { 'Content-Type': 'application/json' },
132+
credentials: 'include',
133+
body: JSON.stringify({ userCode: code }),
134+
});
135+
setDenied(true);
136+
} catch (err: any) {
137+
setError(err?.message ?? 'Deny failed');
138+
} finally {
139+
setDenying(false);
140+
}
141+
};
142+
113143
return (
114144
<PageShell>
115145
<Card>
@@ -119,7 +149,7 @@ function DeviceAuthPage() {
119149
</CardHeader>
120150
<CardContent className="space-y-4">
121151
<div className="rounded-md border bg-background px-4 py-3 text-center">
122-
<p className="text-xs text-muted-foreground mb-1">Device Code</p>
152+
<p className="text-xs text-muted-foreground mb-1">User Code</p>
123153
<p className="font-mono font-semibold tracking-widest text-lg">{code}</p>
124154
</div>
125155

@@ -128,9 +158,12 @@ function DeviceAuthPage() {
128158
Logged in as <span className="font-medium text-foreground">{user.email}</span>
129159
</p>
130160
{error && <p className="text-sm text-destructive text-center">{error}</p>}
131-
<Button onClick={handleApprove} className="w-full" disabled={submitting}>
161+
<Button onClick={handleApprove} className="w-full" disabled={submitting || denying}>
132162
{submitting ? 'Approving…' : 'Approve CLI Access'}
133163
</Button>
164+
<Button onClick={handleDeny} variant="outline" className="w-full" disabled={submitting || denying}>
165+
{denying ? 'Denying…' : 'Deny'}
166+
</Button>
134167
<div className="text-center">
135168
<button
136169
type="button"

apps/server/objectstack.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ async function buildLocalPlugins() {
139139
artifactSource: { mode: 'local-file', path: localArtifactPath },
140140
}),
141141
new ObjectQLPlugin({ environmentId: localProjectId }),
142-
new AuthPlugin({ secret: authSecret, baseUrl, plugins: { organization: true, twoFactor: true, passkeys: false, magicLink: false, oidcProvider: true } }),
142+
new AuthPlugin({ secret: authSecret, baseUrl, plugins: { organization: true, twoFactor: true, passkeys: false, magicLink: false, oidcProvider: true, deviceAuthorization: true } }),
143143
// Short-circuits the control-plane endpoints Studio polls
144144
// (`/cloud/projects*`, `/auth/get-session`, `/auth/organization/list`)
145145
// and exposes `/studio/runtime-config` so the SPA can detect

apps/server/server/control-plane-preset.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export function createControlPlanePlugins(cfg: ControlPlanePresetConfig): any[]
150150
return new AuthPlugin({
151151
secret: cfg.authSecret,
152152
baseUrl: cfg.baseUrl,
153-
plugins: (cfg.authPlugins ?? { organization: true, oidcProvider: true }) as any,
153+
plugins: (cfg.authPlugins ?? { organization: true, oidcProvider: true, deviceAuthorization: true }) as any,
154154
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
155155
advanced: process.env.OBJECTSTACK_COOKIE_DOMAIN
156156
? ({

apps/studio/src/routes/auth.device.tsx

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { CheckCircle2, GalleryVerticalEnd } from 'lucide-react';
1010

1111
export const Route = createFileRoute('/auth/device')({
1212
validateSearch: (search: Record<string, unknown>) => ({
13-
code: (search.code as string) ?? '',
13+
user_code: (search.user_code as string) ?? (search.code as string) ?? '',
1414
}),
1515
component: DeviceAuthPage,
1616
});
@@ -32,12 +32,14 @@ function PageShell({ children }: { children: React.ReactNode }) {
3232
}
3333

3434
function DeviceAuthPage() {
35-
const { code } = Route.useSearch();
35+
const { user_code: code } = Route.useSearch();
3636
const { user, loading } = useSession();
3737
const navigate = useNavigate();
3838

3939
const [submitting, setSubmitting] = useState(false);
40+
const [denying, setDenying] = useState(false);
4041
const [approved, setApproved] = useState(false);
42+
const [denied, setDenied] = useState(false);
4143
const [error, setError] = useState('');
4244

4345
if (!code) {
@@ -46,7 +48,7 @@ function DeviceAuthPage() {
4648
<Card>
4749
<CardHeader className="text-center">
4850
<CardTitle>Invalid Request</CardTitle>
49-
<CardDescription>No device code provided. Please re-run the CLI command.</CardDescription>
51+
<CardDescription>No user code provided. Please re-run the CLI command.</CardDescription>
5052
</CardHeader>
5153
</Card>
5254
</PageShell>
@@ -66,7 +68,7 @@ function DeviceAuthPage() {
6668
}
6769

6870
if (!user) {
69-
return <Navigate to="/login" search={{ redirect: `/auth/device?code=${encodeURIComponent(code)}` }} />;
71+
return <Navigate to="/login" search={{ redirect: `/auth/device?user_code=${encodeURIComponent(code)}` }} />;
7072
}
7173

7274
if (approved) {
@@ -83,23 +85,33 @@ function DeviceAuthPage() {
8385
);
8486
}
8587

88+
if (denied) {
89+
return (
90+
<PageShell>
91+
<Card>
92+
<CardHeader className="text-center">
93+
<CardTitle>Request Denied</CardTitle>
94+
<CardDescription>The CLI login request has been denied.</CardDescription>
95+
</CardHeader>
96+
</Card>
97+
</PageShell>
98+
);
99+
}
100+
86101
const handleApprove = async () => {
87102
setError('');
88103
setSubmitting(true);
89104
try {
90-
const sessionRes = await fetch('/api/v1/auth/get-session', { credentials: 'include' });
91-
const sessionData = await sessionRes.json() as any;
92-
const token = sessionData?.session?.token;
93-
if (!token) throw new Error('No active session');
94-
95105
const res = await fetch('/api/v1/auth/device/approve', {
96106
method: 'POST',
97107
headers: { 'Content-Type': 'application/json' },
98108
credentials: 'include',
99-
body: JSON.stringify({ code, token }),
109+
body: JSON.stringify({ userCode: code }),
100110
});
101-
const data = await res.json() as any;
102-
if (!data.success) throw new Error(data.error?.message ?? 'Approval failed');
111+
if (!res.ok) {
112+
const data = await res.json().catch(() => ({}));
113+
throw new Error((data as any)?.message ?? (data as any)?.error?.message ?? 'Approval failed');
114+
}
103115

104116
setApproved(true);
105117
toast({ title: 'CLI login approved', description: 'The CLI has been authenticated.' });
@@ -110,6 +122,24 @@ function DeviceAuthPage() {
110122
}
111123
};
112124

125+
const handleDeny = async () => {
126+
setError('');
127+
setDenying(true);
128+
try {
129+
await fetch('/api/v1/auth/device/deny', {
130+
method: 'POST',
131+
headers: { 'Content-Type': 'application/json' },
132+
credentials: 'include',
133+
body: JSON.stringify({ userCode: code }),
134+
});
135+
setDenied(true);
136+
} catch (err: any) {
137+
setError(err?.message ?? 'Deny failed');
138+
} finally {
139+
setDenying(false);
140+
}
141+
};
142+
113143
return (
114144
<PageShell>
115145
<Card>
@@ -119,7 +149,7 @@ function DeviceAuthPage() {
119149
</CardHeader>
120150
<CardContent className="space-y-4">
121151
<div className="rounded-md border bg-background px-4 py-3 text-center">
122-
<p className="text-xs text-muted-foreground mb-1">Device Code</p>
152+
<p className="text-xs text-muted-foreground mb-1">User Code</p>
123153
<p className="font-mono font-semibold tracking-widest text-lg">{code}</p>
124154
</div>
125155

@@ -128,9 +158,12 @@ function DeviceAuthPage() {
128158
Logged in as <span className="font-medium text-foreground">{user.email}</span>
129159
</p>
130160
{error && <p className="text-sm text-destructive text-center">{error}</p>}
131-
<Button onClick={handleApprove} className="w-full" disabled={submitting}>
161+
<Button onClick={handleApprove} className="w-full" disabled={submitting || denying}>
132162
{submitting ? 'Approving…' : 'Approve CLI Access'}
133163
</Button>
164+
<Button onClick={handleDeny} variant="outline" className="w-full" disabled={submitting || denying}>
165+
{denying ? 'Denying…' : 'Deny'}
166+
</Button>
134167
<div className="text-center">
135168
<button
136169
type="button"

0 commit comments

Comments
 (0)