Skip to content

Commit 7e85490

Browse files
committed
oauth
1 parent d523c0d commit 7e85490

10 files changed

Lines changed: 474 additions & 3 deletions

File tree

apps/server/objectstack.config.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
3030
import CrmApp from '../../examples/app-crm/objectstack.config';
3131
import TodoApp from '../../examples/app-todo/objectstack.config';
3232
import BiPluginManifest from '../../examples/plugin-bi/objectstack.config';
33+
import type { SocialProviderConfig, OidcProvidersConfig } from '@objectstack/spec/system';
3334
import { fileURLToPath } from 'node:url';
3435
import { dirname, resolve } from 'node:path';
3536

@@ -41,6 +42,64 @@ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
4142
? `https://${process.env.VERCEL_URL}` : undefined)
4243
?? 'http://localhost:3000';
4344

45+
function buildSocialProviders(): SocialProviderConfig | undefined {
46+
const providers: SocialProviderConfig = {};
47+
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
48+
providers.google = {
49+
clientId: process.env.GOOGLE_CLIENT_ID,
50+
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
51+
...(process.env.GOOGLE_OAUTH_SCOPES
52+
? { scope: process.env.GOOGLE_OAUTH_SCOPES.split(',').map((s) => s.trim()) }
53+
: {}),
54+
};
55+
}
56+
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
57+
providers.github = {
58+
clientId: process.env.GITHUB_CLIENT_ID,
59+
clientSecret: process.env.GITHUB_CLIENT_SECRET,
60+
};
61+
}
62+
if (process.env.MICROSOFT_CLIENT_ID && process.env.MICROSOFT_CLIENT_SECRET) {
63+
providers.microsoft = {
64+
clientId: process.env.MICROSOFT_CLIENT_ID,
65+
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
66+
...(process.env.MICROSOFT_TENANT_ID
67+
? { tenantId: process.env.MICROSOFT_TENANT_ID }
68+
: {}),
69+
};
70+
}
71+
if (process.env.APPLE_CLIENT_ID && process.env.APPLE_CLIENT_SECRET) {
72+
providers.apple = {
73+
clientId: process.env.APPLE_CLIENT_ID,
74+
clientSecret: process.env.APPLE_CLIENT_SECRET,
75+
};
76+
}
77+
const keys = Object.keys(providers);
78+
if (keys.length > 0) {
79+
console.info(`[auth] enabled social providers: ${keys.join(', ')}`);
80+
return providers;
81+
}
82+
return undefined;
83+
}
84+
85+
function buildOidcProviders(): OidcProvidersConfig | undefined {
86+
const raw = process.env.OIDC_PROVIDERS;
87+
if (!raw) return undefined;
88+
try {
89+
const parsed = JSON.parse(raw) as OidcProvidersConfig;
90+
if (Array.isArray(parsed) && parsed.length > 0) {
91+
console.info(`[auth] enabled OIDC providers: ${parsed.map(p => p.providerId).join(', ')}`);
92+
return parsed;
93+
}
94+
} catch {
95+
console.warn('[auth] Failed to parse OIDC_PROVIDERS env var — expected a JSON array');
96+
}
97+
return undefined;
98+
}
99+
100+
const socialProviders = buildSocialProviders();
101+
const oidcProviders = buildOidcProviders();
102+
44103
// Turso driver for sys namespace — remote when env vars are configured, local SQLite otherwise
45104
const __dirname = dirname(fileURLToPath(import.meta.url));
46105
const tursoDriver = new TursoDriver(
@@ -99,6 +158,8 @@ export default defineStack({
99158
secret: process.env.AUTH_SECRET ?? 'dev-secret-please-change-in-production-min-32-chars',
100159
baseUrl,
101160
plugins: { organization: true },
161+
...(socialProviders ? { socialProviders } : {}),
162+
...(oidcProviders ? { oidcProviders } : {}),
102163
}),
103164
new SecurityPlugin(),
104165
new AuditPlugin(),
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { useEffect, useState } from 'react';
4+
import { useClient } from '@objectstack/client-react';
5+
import { Button } from '@/components/ui/button';
6+
7+
interface SocialProvider {
8+
id: string;
9+
name: string;
10+
enabled: boolean;
11+
type?: 'social' | 'oidc';
12+
}
13+
14+
interface Props {
15+
mode: 'sign-in' | 'sign-up';
16+
}
17+
18+
export function SocialSignInButtons({ mode }: Props) {
19+
const client = useClient() as any;
20+
const [providers, setProviders] = useState<SocialProvider[]>([]);
21+
const [loading, setLoading] = useState(true);
22+
23+
useEffect(() => {
24+
if (!client?.auth?.getConfig) return;
25+
client.auth.getConfig()
26+
.then((res: any) => {
27+
const list: SocialProvider[] = res?.socialProviders ?? res?.data?.socialProviders ?? [];
28+
setProviders(list.filter((p) => p.enabled));
29+
})
30+
.catch((err: unknown) => {
31+
console.warn('[SocialSignInButtons] failed to load auth config', err);
32+
})
33+
.finally(() => setLoading(false));
34+
}, [client]);
35+
36+
if (loading || providers.length === 0) return null;
37+
38+
const label = mode === 'sign-in' ? 'Continue with' : 'Sign up with';
39+
40+
return (
41+
<div className="flex flex-col gap-2">
42+
{providers.map((p) => (
43+
<Button
44+
key={p.id}
45+
type="button"
46+
variant="outline"
47+
className="w-full"
48+
onClick={() =>
49+
client.auth.signInWithProvider(p.id, {
50+
callbackURL: window.location.origin + '/login',
51+
errorCallbackURL: window.location.origin + '/login',
52+
type: p.type ?? 'social',
53+
})
54+
}
55+
>
56+
{/* TODO: replace with provider icon from lucide-react or simple-icons */}
57+
<span className="mr-2 flex h-4 w-4 items-center justify-center rounded-sm bg-muted text-[10px] font-bold uppercase">
58+
{p.id[0]}
59+
</span>
60+
{label} {p.name}
61+
</Button>
62+
))}
63+
<div className="relative my-1">
64+
<div className="absolute inset-0 flex items-center">
65+
<span className="w-full border-t" />
66+
</div>
67+
<div className="relative flex justify-center text-xs uppercase">
68+
<span className="bg-card px-2 text-muted-foreground">or continue with email</span>
69+
</div>
70+
</div>
71+
</div>
72+
);
73+
}

apps/studio/src/routes/login.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label';
1010
import { toast } from '@/hooks/use-toast';
1111
import { useSession } from '@/hooks/useSession';
1212
import { useProjects } from '@/hooks/useProjects';
13+
import { SocialSignInButtons } from '@/components/auth/social-sign-in-buttons';
1314

1415
export const Route = createFileRoute('/login')({
1516
component: LoginPage,
@@ -76,6 +77,7 @@ function LoginPage() {
7677
</CardHeader>
7778
<form onSubmit={handleSubmit}>
7879
<CardContent className="space-y-4">
80+
<SocialSignInButtons mode="sign-in" />
7981
<div className="space-y-1.5">
8082
<Label htmlFor="email">Email</Label>
8183
<Input

apps/studio/src/routes/register.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
99
import { Label } from '@/components/ui/label';
1010
import { toast } from '@/hooks/use-toast';
1111
import { useSession } from '@/hooks/useSession';
12+
import { SocialSignInButtons } from '@/components/auth/social-sign-in-buttons';
1213

1314
export const Route = createFileRoute('/register')({
1415
component: RegisterPage,
@@ -58,6 +59,7 @@ function RegisterPage() {
5859
</CardHeader>
5960
<form onSubmit={handleSubmit}>
6061
<CardContent className="space-y-4">
62+
<SocialSignInButtons mode="sign-up" />
6163
<div className="space-y-1.5">
6264
<Label htmlFor="name">Name</Label>
6365
<Input

0 commit comments

Comments
 (0)