Skip to content

Commit b92c2cf

Browse files
committed
feat(adk-ui): auto-login for local dev (localtest.me) (#122)
* feat(adk-ui): auto-login for local dev (localtest.me) Closes #120 Signed-off-by: Tomas Weiss <tomas.weiss2@gmail.com> * docs: note Keycloak default auth and admin/admin creds in quickstart Signed-off-by: Tomas Weiss <tomas.weiss2@gmail.com> * fix(adk-ui): address PR review comments Signed-off-by: Tomas Weiss <tomas.weiss2@gmail.com> * refactor(adk-ui): address Petr's PR review comments - move localDevUserSchema/LocalDevUser to types.ts - simplify jwt callback - remove theme param from sign-in handlers and AutoSignIn Signed-off-by: Tomas Weiss <tomas.weiss2@gmail.com> --------- Signed-off-by: Tomas Weiss <tomas.weiss2@gmail.com>
1 parent 726ffc6 commit b92c2cf

8 files changed

Lines changed: 128 additions & 25 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ All commits must be signed off for DCO compliance (`git commit --signoff`).
1212

1313
- `mise run adk-server:migrations:run` run migrations
1414

15+
## Docs
16+
17+
- Only edit docs under `docs/development/`, never `docs/stable/`
18+
1519
## Development rules
1620

1721
- When working in adk-server make sure you always test the behaviour using the adk-server debugging approach

apps/adk-ui/src/app/(auth)/auth.ts

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,77 @@
55

66
import NextAuth from 'next-auth';
77
import type { OIDCConfig } from 'next-auth/providers';
8+
import Credentials from 'next-auth/providers/credentials';
9+
import { z } from 'zod';
810

911
import { runtimeConfig } from '#contexts/App/runtime-config.ts';
1012
import { routes } from '#utils/router.ts';
1113

12-
import type { ProviderConfig, ProviderWithId } from './types';
13-
import { providerConfigSchema } from './types';
14+
import type { LocalDevUser, ProviderConfig, ProviderWithId } from './types';
15+
import { localDevUserSchema, providerConfigSchema } from './types';
1416
import { getTokenRefreshSchedule, jwtWithRefresh, RefreshTokenError } from './utils';
1517

1618
export const AUTH_COOKIE_NAME = 'adk-auth-token';
1719

18-
const { isAuthEnabled } = runtimeConfig;
20+
const { isAuthEnabled, isLocalDevAutoLogin } = runtimeConfig;
21+
22+
const oidcConfigSchema = z.object({
23+
issuer: z.string(),
24+
clientId: z.string(),
25+
clientSecret: z.string(),
26+
});
27+
28+
function createLocalDevCredentialsProvider(config: z.infer<typeof oidcConfigSchema>) {
29+
return Credentials({
30+
id: 'local-dev',
31+
name: 'Local Dev',
32+
credentials: {
33+
username: { label: 'Username', type: 'text' },
34+
password: { label: 'Password', type: 'password' },
35+
},
36+
authorize: async (credentials) => {
37+
const { username, password } = z
38+
.object({
39+
username: z.string(),
40+
password: z.string(),
41+
})
42+
.parse(credentials);
43+
44+
const tokenEndpoint = `${config.issuer.replace(/\/$/, '')}/protocol/openid-connect/token`;
45+
46+
console.info(`[local-dev] fetching token from ${tokenEndpoint} for user "${username}"`);
47+
const response = await fetch(tokenEndpoint, {
48+
method: 'POST',
49+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
50+
body: new URLSearchParams({
51+
grant_type: 'password',
52+
client_id: config.clientId,
53+
client_secret: config.clientSecret,
54+
username,
55+
password,
56+
scope: 'openid email profile',
57+
}),
58+
});
59+
60+
if (!response.ok) {
61+
const body = await response.text();
62+
console.error(`[local-dev] token request failed: ${response.status} ${response.statusText}${body}`);
63+
return null;
64+
}
65+
66+
const tokens = await response.json();
67+
console.info(`[local-dev] token request succeeded, expires_in=${tokens.expires_in}`);
68+
return {
69+
id: username,
70+
name: username,
71+
access_token: tokens.access_token,
72+
refresh_token: tokens.refresh_token,
73+
expires_in: tokens.expires_in,
74+
expires_at: Math.floor(Date.now() / 1000 + tokens.expires_in),
75+
} satisfies LocalDevUser;
76+
},
77+
});
78+
}
1979

2080
function createOIDCProvider(config: ProviderConfig): OIDCConfig<unknown> {
2181
const useInternalBackChannel = config.external_issuer && config.external_issuer !== config.issuer;
@@ -86,13 +146,33 @@ export function getProvider(): ProviderWithId | null {
86146
}
87147
}
88148

149+
function assembleProviders(oidcProvider: ProviderWithId | null) {
150+
if (isLocalDevAutoLogin) {
151+
return [
152+
createLocalDevCredentialsProvider(
153+
oidcConfigSchema.parse({
154+
issuer: process.env.OIDC_PROVIDER_ISSUER,
155+
clientId: process.env.OIDC_PROVIDER_CLIENT_ID,
156+
clientSecret: process.env.OIDC_PROVIDER_CLIENT_SECRET,
157+
}),
158+
),
159+
];
160+
}
161+
162+
if (oidcProvider) {
163+
return [oidcProvider];
164+
}
165+
166+
return [];
167+
}
168+
89169
const provider = getProvider();
90170

91171
// Prevents nextauth errors when authentication is disabled and NEXTAUTH_SECRET is not provided
92172
export const AUTH_SECRET = isAuthEnabled ? process.env.NEXTAUTH_SECRET : 'dummy_secret';
93173

94174
export const { handlers, signIn, signOut, auth } = NextAuth({
95-
providers: provider ? [provider] : [],
175+
providers: assembleProviders(provider),
96176
pages: {
97177
signIn: routes.signIn(),
98178
},
@@ -113,18 +193,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
113193
authorized: ({ auth }) => {
114194
return isAuthEnabled ? Boolean(auth) : true;
115195
},
116-
jwt: async ({ token, account, trigger, session }) => {
196+
jwt: async ({ token, account, user, trigger, session }) => {
117197
if (trigger === 'update') {
118198
token.name = session.user.name;
119199
}
120200

121-
// pull the id token out of the account on signIn
122201
if (account) {
123-
token.accessToken = account.access_token;
202+
const src = account.type === 'credentials' ? localDevUserSchema.parse(user) : account;
203+
token.accessToken = src.access_token;
124204
token.provider = account.provider;
125-
token.refreshToken = account.refresh_token;
126-
token.expiresIn = account.expires_in;
127-
token.expiresAt = account.expires_at;
205+
token.refreshToken = src.refresh_token;
206+
token.expiresIn = src.expires_in;
207+
token.expiresAt = src.expires_at;
128208
token.refreshSchedule = getTokenRefreshSchedule(token.expiresAt);
129209
}
130210

apps/adk-ui/src/app/(auth)/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,14 @@ export const providerConfigSchema = z.preprocess((val) => {
3030
export type ProviderConfig = z.infer<typeof providerConfigSchema>;
3131

3232
export type ProviderWithId = Provider & { id: string };
33+
34+
export const localDevUserSchema = z.object({
35+
id: z.string(),
36+
name: z.string(),
37+
access_token: z.string(),
38+
refresh_token: z.string(),
39+
expires_in: z.number(),
40+
expires_at: z.number(),
41+
});
42+
43+
export type LocalDevUser = z.infer<typeof localDevUserSchema>;

apps/adk-ui/src/contexts/App/runtime-config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,7 @@ export const runtimeConfig: RuntimeConfig = {
2222
defaults: contextTokenPermissionsDefaults,
2323
}),
2424
isAuthEnabled: process.env.OIDC_ENABLED !== 'false',
25+
isLocalDevAutoLogin:
26+
process.env.OIDC_ENABLED !== 'false' && (process.env.OIDC_PROVIDER_ISSUER?.includes('localtest.me') ?? false),
2527
appName: process.env.APP_NAME || 'Kagenti ADK',
2628
};

apps/adk-ui/src/contexts/App/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ export interface RuntimeConfig {
1414
featureFlags: FeatureFlags;
1515
contextTokenPermissions: ContextTokenPermissions;
1616
isAuthEnabled: boolean;
17+
isLocalDevAutoLogin: boolean;
1718
appName: string;
1819
}

apps/adk-ui/src/modules/auth/components/AutoSignIn.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,13 @@
66
'use client';
77
import { useEffect } from 'react';
88

9-
import { useTheme } from '#contexts/Theme/index.ts';
10-
import type { ThemePreference } from '#contexts/Theme/theme-context.ts';
11-
129
interface Props {
13-
signIn: (theme: ThemePreference) => Promise<void>;
10+
signIn: () => Promise<void>;
1411
}
1512

1613
export function AutoSignIn({ signIn }: Props) {
17-
const { themePreference } = useTheme();
18-
1914
useEffect(() => {
20-
void signIn(themePreference);
21-
}, [signIn, themePreference]);
15+
void signIn();
16+
}, [signIn]);
2217
return <div>Redirecting to login...</div>;
2318
}

apps/adk-ui/src/modules/auth/components/SignInProviders.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { redirect } from 'next/navigation';
77
import { AuthError } from 'next-auth';
88

99
import { auth, getProvider, signIn } from '#app/(auth)/auth.ts';
10-
import type { ThemePreference } from '#contexts/Theme/theme-context.ts';
10+
import { runtimeConfig } from '#contexts/App/runtime-config.ts';
1111
import { routes } from '#utils/router.ts';
1212

1313
import { AuthErrorPage } from './AuthErrorPage';
@@ -31,17 +31,23 @@ export async function SignInProviders({ callbackUrl = routes.home() }: Props) {
3131
return <AuthErrorPage callbackUrl={callbackUrl} />;
3232
}
3333

34-
return <AutoSignIn signIn={handleSignIn.bind(null, { providerId: authProvider.id, redirectTo: callbackUrl })} />;
34+
const signInAction = runtimeConfig.isLocalDevAutoLogin
35+
? handleLocalDevSignIn.bind(null, callbackUrl)
36+
: handleSignIn.bind(null, { providerId: authProvider.id, redirectTo: callbackUrl });
37+
38+
return <AutoSignIn signIn={signInAction} />;
39+
}
40+
41+
async function handleLocalDevSignIn(redirectTo: string) {
42+
'use server';
43+
await signIn('local-dev', { username: 'admin', password: 'admin', redirectTo });
3544
}
3645

37-
async function handleSignIn(
38-
{ providerId, redirectTo }: { providerId: string; redirectTo: string },
39-
theme: ThemePreference,
40-
) {
46+
async function handleSignIn({ providerId, redirectTo }: { providerId: string; redirectTo: string }) {
4147
'use server';
4248

4349
try {
44-
await signIn(providerId, { redirectTo }, { kc_theme: theme });
50+
await signIn(providerId, { redirectTo });
4551
} catch (error) {
4652
// Sign-in can fail for a number of reasons, such as the user not existing, or the user not having the correct role.
4753
// In some cases, you may want to redirect to a custom error.

docs/development/introduction/quickstart.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ kagenti-adk info chat # View agent details
182182
kagenti-adk --help # See all options
183183
```
184184

185+
<Info>
186+
The default installation includes Keycloak for authentication. When accessing the web UI locally, you will be automatically logged in. To log in manually or access Keycloak directly, use the default credentials: `admin` / `admin`.
187+
</Info>
188+
185189
## Platform Management
186190

187191
```sh

0 commit comments

Comments
 (0)