Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support direct sign-in #5536

Merged
merged 4 commits into from
Mar 26, 2024
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
27 changes: 27 additions & 0 deletions .changeset/hip-ladybugs-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@logto/core": minor
---

support `first_screen` parameter in authentication request

Sign-in experience can be initiated with a specific screen by setting the `first_screen` parameter in the OIDC authentication request. This parameter is intended to replace the `interaction_mode` parameter, which is now deprecated.

The `first_screen` parameter can have the following values:

- `signIn`: The sign-in screen is displayed first.
- `register`: The registration screen is displayed first.

Here's a non-normative example of how to use the `first_screen` parameter:

```
GET /authorize?
response_type=code
&client_id=your_client_id
&redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
&scope=openid
&state=af0ifjsldkj
&nonce=n-0S6_WzA2Mj
&first_screen=signIn
```

When `first_screen` is set, the legacy `interaction_mode` parameter is ignored.
gao-sun marked this conversation as resolved.
Show resolved Hide resolved
9 changes: 9 additions & 0 deletions .changeset/nasty-beds-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@logto/schemas": minor
---

add oidc params variables and types

- Add `ExtraParamsKey` enum for all possible OIDC extra parameters that Logto supports.
- Add `FirstScreen` enum for the `first_screen` parameter.
- Add `extraParamsObjectGuard` guard and `ExtraParamsObject` type for shaping the extra parameters object in the OIDC authentication request.
15 changes: 15 additions & 0 deletions .changeset/rude-radios-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@logto/experience": minor
"@logto/core": minor
---

support direct sign-in

Instead of showing a screen for the user to choose between the sign-in methods, a specific sign-in method can be initiated directly by setting the `direct_sign_in` parameter in the OIDC authentication request.

This parameter follows the format of `direct_sign_in=<method>:<target>`, where:

- `<method>` is the sign-in method to trigger. Currently the only supported value is `social`.
- `<target>` is the target value for the sign-in method. If the method is `social`, the value is the social connector's `target`.

When a valid `direct_sign_in` parameter is set, the first screen will be skipped and the specified sign-in method will be triggered immediately upon entering the sign-in experience. If the parameter is invalid, the default behavior of showing the first screen will be used.
7 changes: 7 additions & 0 deletions .changeset/smart-walls-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@logto/demo-app": minor
---

carry over search params to the authentication request

When entering the Logto demo app with search parameters, if the user is not authenticated, the search parameters are now carried over to the authentication request. This allows manual testing of the OIDC authentication flow with specific parameters.
2 changes: 0 additions & 2 deletions packages/console/src/pages/Callback/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ import { useHandleSignInCallback } from '@logto/react';
import { useNavigate } from 'react-router-dom';

import AppLoading from '@/components/AppLoading';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import { consumeSavedRedirect } from '@/utils/storage';

/** The global callback page for all sign-in redirects from Logto main flow. */
function Callback() {
const { getTo } = useTenantPathname();
const navigate = useNavigate();

useHandleSignInCallback(() => {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/libraries/verification-status.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { generateStandardId } from '@logto/shared';

import RequestError from '#src/errors/RequestError/index.js';
import { verificationTimeout } from '#src/routes/consts.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';

const verificationTimeout = 10 * 60 * 1000; // 10 mins

export const createVerificationStatusLibrary = (queries: Queries) => {
const {
findVerificationStatusByUserId,
Expand Down
27 changes: 12 additions & 15 deletions packages/core/src/oidc/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
import {
customClientMetadataDefault,
CustomClientMetadataKey,
demoAppApplicationId,
experience,
extraParamsObjectGuard,
inSeconds,
logtoCookieKey,
type LogtoUiCookie,
LogtoJwtTokenKey,
ExtraParamsKey,
} from '@logto/schemas';
import { conditional, trySafe, tryThat } from '@silverhand/essentials';
import i18next from 'i18next';
Expand All @@ -28,8 +30,11 @@
import koaAuditLog from '#src/middleware/koa-audit-log.js';
import koaBodyEtag from '#src/middleware/koa-body-etag.js';
import postgresAdapter from '#src/oidc/adapter.js';
import { isOriginAllowed, validateCustomClientMetadata } from '#src/oidc/utils.js';
import { routes } from '#src/routes/consts.js';
import {
buildLoginPromptUrl,
isOriginAllowed,
validateCustomClientMetadata,
} from '#src/oidc/utils.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';

Expand All @@ -43,12 +48,11 @@
filterResourceScopesForTheThirdPartyApplication,
} from './resource.js';
import { getAcceptedUserClaims, getUserClaimsData } from './scope.js';
import { OIDCExtraParametersKey, InteractionMode } from './type.js';

// Temporarily removed 'EdDSA' since it's not supported by browser yet
const supportedSigningAlgs = Object.freeze(['RS256', 'PS256', 'ES256', 'ES384', 'ES512'] as const);

export default function initOidc(

Check warning on line 55 in packages/core/src/oidc/init.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/oidc/init.ts#L55

[max-params] Function 'initOidc' has too many parameters (5). Maximum allowed is 4.
envSet: EnvSet,
queries: Queries,
libraries: Libraries,
Expand Down Expand Up @@ -174,8 +178,6 @@
},
interactions: {
url: (ctx, { params: { client_id: appId }, prompt }) => {
const isDemoApp = appId === demoAppApplicationId;

ctx.cookies.set(
logtoCookieKey,
JSON.stringify({
Expand All @@ -184,20 +186,15 @@
{ sameSite: 'lax', overwrite: true, httpOnly: false }
);

const appendParameters = (path: string) => {
return isDemoApp ? path + `?no_cache` : path;
};
const params = trySafe(() => extraParamsObjectGuard.parse(ctx.oidc.params ?? {})) ?? {};

switch (prompt.name) {
case 'login': {
const isSignUp =
ctx.oidc.params?.[OIDCExtraParametersKey.InteractionMode] === InteractionMode.signUp;

return appendParameters(isSignUp ? routes.signUp : routes.signIn);
return '/' + buildLoginPromptUrl(params, appId);
}

case 'consent': {
return routes.consent;
return '/' + experience.routes.consent;
}

default: {
Expand All @@ -206,7 +203,7 @@
}
},
},
extraParams: [OIDCExtraParametersKey.InteractionMode],
extraParams: Object.values(ExtraParamsKey),
extraTokenClaims: async (ctx, token) => {
const { isDevFeaturesEnabled, isCloud } = EnvSet.values;

Expand Down Expand Up @@ -257,7 +254,7 @@
},
});
} catch {
// TODO: Log the error

Check warning on line 257 in packages/core/src/oidc/init.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/oidc/init.ts#L257

[no-warning-comments] Unexpected 'todo' comment: 'TODO: Log the error'.
}
},
extraClientMetadata: {
Expand Down
8 changes: 0 additions & 8 deletions packages/core/src/oidc/type.ts

This file was deleted.

56 changes: 55 additions & 1 deletion packages/core/src/oidc/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { ApplicationType, CustomClientMetadataKey, GrantType } from '@logto/schemas';
import {
ApplicationType,
CustomClientMetadataKey,
FirstScreen,
GrantType,
InteractionMode,
demoAppApplicationId,
} from '@logto/schemas';

import { mockEnvSet } from '#src/test-utils/env-set.js';

Expand All @@ -7,6 +14,7 @@ import {
buildOidcClientMetadata,
getConstantClientMetadata,
validateCustomClientMetadata,
buildLoginPromptUrl,
} from './utils.js';

describe('getConstantClientMetadata()', () => {
Expand Down Expand Up @@ -121,3 +129,49 @@ describe('isOriginAllowed', () => {
).toBeTruthy();
});
});

describe('buildLoginPromptUrl', () => {
it('should return the correct url for empty parameters', () => {
expect(buildLoginPromptUrl({})).toBe('sign-in');
expect(buildLoginPromptUrl({}, 'foo')).toBe('sign-in');
expect(buildLoginPromptUrl({}, demoAppApplicationId)).toBe('sign-in?no_cache=');
});

it('should return the correct url for firstScreen', () => {
expect(buildLoginPromptUrl({ first_screen: FirstScreen.Register })).toBe('register');
expect(buildLoginPromptUrl({ first_screen: FirstScreen.Register }, 'foo')).toBe('register');
expect(buildLoginPromptUrl({ first_screen: FirstScreen.SignIn }, demoAppApplicationId)).toBe(
'sign-in?no_cache='
);
// Legacy interactionMode support
expect(buildLoginPromptUrl({ interaction_mode: InteractionMode.SignUp })).toBe('register');
});

it('should return the correct url for directSignIn', () => {
expect(buildLoginPromptUrl({ direct_sign_in: 'method:target' })).toBe(
'direct/method/target?fallback=sign-in'
);
expect(buildLoginPromptUrl({ direct_sign_in: 'method:target' }, 'foo')).toBe(
'direct/method/target?fallback=sign-in'
);
expect(buildLoginPromptUrl({ direct_sign_in: 'method:target' }, demoAppApplicationId)).toBe(
'direct/method/target?no_cache=&fallback=sign-in'
);
expect(buildLoginPromptUrl({ direct_sign_in: 'method' })).toBe(
'direct/method?fallback=sign-in'
);
expect(buildLoginPromptUrl({ direct_sign_in: '' })).toBe('sign-in');
});

it('should return the correct url for mixed parameters', () => {
expect(
buildLoginPromptUrl({ first_screen: FirstScreen.Register, direct_sign_in: 'method:target' })
).toBe('direct/method/target?fallback=register');
expect(
buildLoginPromptUrl(
{ first_screen: FirstScreen.Register, direct_sign_in: 'method:target' },
demoAppApplicationId
)
).toBe('direct/method/target?no_cache=&fallback=register');
});
});
38 changes: 36 additions & 2 deletions packages/core/src/oidc/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import type { CustomClientMetadata, OidcClientMetadata } from '@logto/schemas';
import { ApplicationType, customClientMetadataGuard, GrantType } from '@logto/schemas';
import path from 'node:path';

import type { CustomClientMetadata, ExtraParamsObject, OidcClientMetadata } from '@logto/schemas';
import {
ApplicationType,
customClientMetadataGuard,
GrantType,
ExtraParamsKey,
demoAppApplicationId,
FirstScreen,
experience,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { type AllClientMetadata, type ClientAuthMethod, errors } from 'oidc-provider';

Expand Down Expand Up @@ -69,3 +79,27 @@ export const getUtcStartOfTheDay = (date: Date) => {
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0)
);
};

export const buildLoginPromptUrl = (params: ExtraParamsObject, appId?: unknown): string => {
const firstScreenKey =
params[ExtraParamsKey.FirstScreen] ??
params[ExtraParamsKey.InteractionMode] ??
FirstScreen.SignIn;
const firstScreen =
firstScreenKey === 'signUp' ? experience.routes.register : experience.routes[firstScreenKey];
const directSignIn = params[ExtraParamsKey.DirectSignIn];
const searchParams = new URLSearchParams();
const getSearchParamString = () => (searchParams.size > 0 ? `?${searchParams.toString()}` : '');

if (appId === demoAppApplicationId) {
searchParams.append('no_cache', '');
}

if (directSignIn) {
searchParams.append('fallback', firstScreen);
const [method, target] = directSignIn.split(':');
return path.join('direct', method ?? '', target ?? '') + getSearchParamString();
}

return firstScreen + getSearchParamString();
};
11 changes: 0 additions & 11 deletions packages/core/src/routes/consts.ts

This file was deleted.

5 changes: 2 additions & 3 deletions packages/core/src/tenants/Tenant.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { adminTenantId } from '@logto/schemas';
import { adminTenantId, experience } from '@logto/schemas';
import type { MiddlewareType } from 'koa';
import Koa from 'koa';
import compose from 'koa-compose';
Expand All @@ -25,7 +25,6 @@ import koaSpaProxy from '#src/middleware/koa-spa-proxy.js';
import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js';
import initOidc from '#src/oidc/init.js';
import { mountCallbackRouter } from '#src/routes/callback.js';
import { routes } from '#src/routes/consts.js';
import initApis from '#src/routes/init.js';
import initMeApis from '#src/routes-me/init.js';
import BasicSentinel from '#src/sentinel/basic-sentinel.js';
Expand Down Expand Up @@ -147,7 +146,7 @@ export default class Tenant implements TenantContext {
app.use(
compose([
koaSpaSessionGuard(provider, queries),
mount(`${routes.consent}`, koaAutoConsent(provider, queries)),
mount(`/${experience.routes.consent}`, koaAutoConsent(provider, queries)),
koaSpaProxy(mountedApps),
])
);
Expand Down
5 changes: 4 additions & 1 deletion packages/demo-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ const Main = () => {

// If user is not authenticated, redirect to sign-in page
if (!isAuthenticated) {
void signIn(window.location.href);
void signIn({
redirectUri: window.location.origin + window.location.pathname,
extraParams: Object.fromEntries(new URLSearchParams(window.location.search).entries()),
});
}
}, [getIdTokenClaims, isAuthenticated, isInCallback, isLoading, signIn, user]);

Expand Down
Loading
Loading