Skip to content

Commit

Permalink
feat: add firstScreen, directSignIn and loginHint
Browse files Browse the repository at this point in the history
add firstScreen, directSignIn, and loginHint support
  • Loading branch information
simeng-li committed Sep 6, 2024
1 parent 554a0d3 commit 07e353b
Show file tree
Hide file tree
Showing 4 changed files with 237 additions and 53 deletions.
9 changes: 8 additions & 1 deletion lib/logto_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import '/src/modules/pkce.dart';
import '/src/modules/token_storage.dart';
import '/src/utilities/utils.dart' as utils;
import 'logto_core.dart' as logto_core;
import '/src/utilities/constants.dart';

export '/src/exceptions/logto_auth_exceptions.dart';
export '/src/interfaces/logto_interfaces.dart';
Expand Down Expand Up @@ -188,7 +189,10 @@ class LogtoClient {

// Sign in using the PKCE flow.
Future<void> signIn(String redirectUri,
[logto_core.InteractionMode? interactionMode]) async {
{logto_core.InteractionMode? interactionMode,
String? loginHint,
String? directSignIn,
FirstScreen? firstScreen}) async {
if (_loading) {
throw LogtoAuthException(
LogtoAuthExceptions.isLoadingError, 'Already signing in...');
Expand All @@ -212,6 +216,9 @@ class LogtoClient {
resources: config.resources,
scopes: config.scopes,
interactionMode: interactionMode,
loginHint: loginHint,
firstScreen: firstScreen,
directSignIn: directSignIn,
);

final redirectUriScheme = Uri.parse(redirectUri).scheme;
Expand Down
105 changes: 64 additions & 41 deletions lib/logto_core.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,12 @@ const String _requestContentType = 'application/x-www-form-urlencoded';

/**
* logto_core.dart
*
* This file is part of the Logto SDK.
*
* This file is part of the Logto SDK.
* It contains the core functionalities of the OIDC authentication flow.
* Use this module if you want to build your own custom SDK.
*/

/**
* By default Logto use sign-in as the landing page for the user.
* Use this enum to specify the interaction mode.
*
* - signIn: The user will be redirected to the sign-in page.
* - signUp: The user will be redirected to the sign-up page.
*/
enum InteractionMode { signIn, signUp }

extension InteractionModeExtension on InteractionMode {
String get value {
switch (this) {
case InteractionMode.signIn:
return 'signIn';
case InteractionMode.signUp:
return 'signUp';
default:
throw Exception("Invalid value");
}
}
}

/**
* Fetch the OIDC provider configuration.
*/
Expand Down Expand Up @@ -98,7 +76,6 @@ Future<LogtoRefreshTokenResponse> fetchTokenByRefreshToken({
required String refreshToken,
String? resource,
String? organizationId,
List<String>? scopes,
}) async {
Map<String, dynamic> payload = {
'grant_type': refreshTokenGrantType,
Expand All @@ -114,10 +91,6 @@ Future<LogtoRefreshTokenResponse> fetchTokenByRefreshToken({
payload.addAll({'organization_id': organizationId});
}

if (scopes != null && scopes.isNotEmpty) {
payload.addAll({'scope': scopes.join(' ')});
}

final response = await httpClient.post(Uri.parse(tokenEndPoint),
headers: {'Content-Type': _requestContentType}, body: payload);

Expand Down Expand Up @@ -158,16 +131,40 @@ Future<void> revoke({
* Generate the sign-in URI (Authorization URI).
* This URI will be used to initiate the OIDC authentication flow.
*/
Uri generateSignInUri(
{required String authorizationEndpoint,
required clientId,
required String redirectUri,
required String codeChallenge,
required String state,
List<String>? scopes,
List<String>? resources,
InteractionMode? interactionMode,
String prompt = _prompt}) {
Uri generateSignInUri({
required String authorizationEndpoint,
required clientId,
required String redirectUri,
required String codeChallenge,
required String state,
String prompt = _prompt,
List<String>? scopes,
List<String>? resources,
String? loginHint,
@Deprecated('Legacy parameter, use firstScreen instead')
InteractionMode? interactionMode,
/**
* Direct sign-in is a feature that allows you to skip the sign-in page,
* and directly sign in the user using a specific social or sso connector.
*
* The format should be `social:{connector}` or `sso:{connector}`.
*/
String? directSignIn,
/**
* The first screen to be shown in the sign-in experience.
*/
FirstScreen? firstScreen,
/**
* Extra parameters to be added to the sign-in URI.
*/
Map<String, String>? extraParams,
}) {
assert(
isValidDirectSignInFormat(directSignIn),
'Invalid format for directSignIn: $directSignIn, '
'expected one of `social:{connector}` or `sso:{connector}`',
);

var signInUri = Uri.parse(authorizationEndpoint);

Map<String, dynamic> queryParameters = {
Expand All @@ -194,16 +191,32 @@ Uri generateSignInUri(
queryParameters.addAll({'resource': resources});
}

if (loginHint != null) {
queryParameters.addAll({'login_hint': loginHint});
}

if (interactionMode != null) {
// need to align with the backend OIDC params name
queryParameters.addAll({'interaction_mode': interactionMode.value});
}

if (directSignIn != null) {
queryParameters.addAll({'direct_sign_in': directSignIn});
}

if (firstScreen != null) {
queryParameters.addAll({'first_screen': firstScreen.value});
}

if (extraParams != null) {
queryParameters.addAll(extraParams);
}

return addQueryParameters(signInUri, queryParameters);
}

/**
* Generate the sign-out URI (End Session URI).
* Generate the sign-out URI (End Session URI).
*/
Uri generateSignOutUri({
required String endSessionEndpoint,
Expand All @@ -220,7 +233,7 @@ Uri generateSignOutUri({

/**
* A utility function to verify and parse the code from the authorization callback URI.
*
*
* - verify the callback URI
* - verify the state
* - error detection
Expand Down Expand Up @@ -257,3 +270,13 @@ String verifyAndParseCodeFromCallbackUri(

return queryParams['code']!;
}

/**
* Verify the direct sign-in parameter format.
*/
bool isValidDirectSignInFormat(String? directSignIn) {
if (directSignIn == null) return true;

RegExp regex = RegExp(r'^(social|sso):[a-zA-Z0-9]+$');
return regex.hasMatch(directSignIn);
}
57 changes: 57 additions & 0 deletions lib/src/utilities/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,60 @@ String getOrganizationIdFromUrn(String organizationUrn) {

return organizationUrn.substring(organizationUrnPrefix.length);
}

/**
* @Deprecated use firstScreen instead
*
* By default Logto use sign-in as the landing page for the user.
* Use this enum to specify the interaction mode.
*
* - signIn: The user will be redirected to the sign-in page.
* - signUp: The user will be redirected to the sign-up page.
*/
@Deprecated('Legacy parameter, use firstScreen instead')
enum InteractionMode { signIn, signUp }

extension InteractionModeExtension on InteractionMode {
String get value {
switch (this) {
case InteractionMode.signIn:
return 'signIn';
case InteractionMode.signUp:
return 'signUp';
default:
throw Exception("Invalid value");
}
}
}

/**
* The first screen to be shown in the sign-in experience.
*
* Note it's not a part of the OIDC standard, but a Logto-specific extension.
*/
enum FirstScreen {
signIn,
register,
identifierSignIn,
identifierRegister,
singleSignOn,
}

extension FirstScreenExtension on FirstScreen {
String get value {
switch (this) {
case FirstScreen.signIn:
return 'sign_in';
case FirstScreen.register:
return 'register';
case FirstScreen.identifierSignIn:
return 'identifier:sign_in';
case FirstScreen.identifierRegister:
return 'identifier:register';
case FirstScreen.singleSignOn:
return 'single_sign_on';
default:
throw Exception("Invalid value");
}
}
}
119 changes: 108 additions & 11 deletions test/logto_core_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ void main() {
nock.cleanAll();
});

const String authorizationEndpoint = 'http://foo.com';
const clientId = 'foo_client';
var redirectUri = 'http://foo.app.io';
const String codeChallenge = 'foo_code_challenge';
const String state = 'foo_state';

test('Generate SignIn Uri', () {
const String authorizationEndpoint = 'http://foo.com';
const clientId = 'foo_client';
var redirectUri = 'http://foo.app.io';
const String codeChallenge = 'foo_code_challenge';
const String state = 'foo_state';
const InteractionMode interactionMode = InteractionMode.signUp;

var signInUri = logto_core.generateSignInUri(
Expand Down Expand Up @@ -56,12 +57,6 @@ void main() {
});

test('Generate SignIn Uri with organization scope', () {
const String authorizationEndpoint = 'http://foo.com';
const clientId = 'foo_client';
var redirectUri = 'http://foo.app.io';
const String codeChallenge = 'foo_code_challenge';
const String state = 'foo_state';

var signInUri = logto_core.generateSignInUri(
authorizationEndpoint: authorizationEndpoint,
clientId: clientId,
Expand All @@ -77,6 +72,108 @@ void main() {
['http://foo.api', LogtoReservedResource.organization.value]));
});

test('Generate SignIn Uri with direct sign in specified', () {
const String directSignIn = 'social:connector';

var signInUri = logto_core.generateSignInUri(
authorizationEndpoint: authorizationEndpoint,
clientId: clientId,
redirectUri: redirectUri,
codeChallenge: codeChallenge,
resources: ['http://foo.api'],
state: state,
directSignIn: directSignIn);

expect(signInUri.queryParameters,
containsPair('direct_sign_in', directSignIn));
});

test('SignIn Uri with direct sign starting with `social:` passes validation',
() {
const String directSignIn = 'social:connector';

var signInUri = logto_core.generateSignInUri(
authorizationEndpoint: authorizationEndpoint,
clientId: clientId,
redirectUri: redirectUri,
codeChallenge: codeChallenge,
resources: ['http://foo.api'],
state: state,
directSignIn: directSignIn);

expect(signInUri.queryParameters,
containsPair('direct_sign_in', directSignIn));
});

test('SignIn Uri with direct sign starting with `sso:` passes validation',
() {
const String directSignIn = 'sso:connector';

var signInUri = logto_core.generateSignInUri(
authorizationEndpoint: authorizationEndpoint,
clientId: clientId,
redirectUri: redirectUri,
codeChallenge: codeChallenge,
resources: ['http://foo.api'],
state: state,
directSignIn: directSignIn);

expect(signInUri.queryParameters,
containsPair('direct_sign_in', directSignIn));
});

test('SignIn Uri with direct sign starting with `wrong:` fails validation',
() {
const String directSignIn = 'wrong:connector';

expect(
() => logto_core.generateSignInUri(
authorizationEndpoint: authorizationEndpoint,
clientId: clientId,
redirectUri: redirectUri,
codeChallenge: codeChallenge,
resources: ['http://foo.api'],
state: state,
directSignIn: directSignIn),
throwsA(predicate((e) =>
e is AssertionError &&
e.message ==
'Invalid format for directSignIn: $directSignIn, '
'expected one of `social:{connector}` or `sso:{connector}`')));
});

test('SignIn Uri with firstScreen and loginHint', () {
const FirstScreen firstScreen = FirstScreen.identifierRegister;
const String loginHint = '[email protected]';

var signInUri = logto_core.generateSignInUri(
authorizationEndpoint: authorizationEndpoint,
clientId: clientId,
redirectUri: redirectUri,
codeChallenge: codeChallenge,
state: state,
firstScreen: firstScreen,
loginHint: loginHint);

expect(signInUri.queryParameters,
containsPair('first_screen', FirstScreen.identifierRegister.value));
expect(signInUri.queryParameters, containsPair('login_hint', loginHint));
});

test('SignIn Uri with extraParams', () {
const Map<String, String> extraParams = {'foo': 'bar'};

var signInUri = logto_core.generateSignInUri(
authorizationEndpoint: authorizationEndpoint,
clientId: clientId,
redirectUri: redirectUri,
codeChallenge: codeChallenge,
state: state,
extraParams: extraParams);

expect(signInUri.queryParameters, containsPair('foo', 'bar'));
});

test('Generate SignOut Uri', () {
const String endSessionEndpoint = 'https://foo.com';
const String clientId = 'foo_client';
Expand Down

0 comments on commit 07e353b

Please sign in to comment.