Skip to content

Commit

Permalink
feat(gotrue, supabase_flutter): Add identity linking and unlinking me…
Browse files Browse the repository at this point in the history
…thods. (#760)

* add identity linking methods to gotrue

* update gotrue docker version

* add provider_id to gotrue test insert statements

* fix: typo

* enable manual linkin on docker compose

* update env variable

* update schema

* update identity_id

* add uuid back to provider_id

* fix tpyo

* fix: processing request

* identity linking working on flutter web

* add link identity method

* fix test

* Call getUser instead of refreshSession within getUserIdentities

* make url in OAuthResponse String

* fix analysis error
  • Loading branch information
dshukertjr authored Jan 2, 2024
1 parent fc42613 commit 6c0c922
Show file tree
Hide file tree
Showing 8 changed files with 132 additions and 25 deletions.
3 changes: 3 additions & 0 deletions infra/gotrue/db/00-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ VALUES -- For unverified factors

INSERT INTO auth.identities (
id,
provider_id,
user_id,
identity_data,
provider,
Expand All @@ -93,6 +94,7 @@ INSERT INTO auth.identities (
updated_at
)
VALUES (
'18bc7a4e-c095-4573-93dc-e0be29bada97',
'18bc7a4e-c095-4573-93dc-e0be29bada97',
'18bc7a4e-c095-4573-93dc-e0be29bada97',
'{"sub": "18bc7a4e-c095-4573-93dc-e0be29bada97", "email": "fake1@email.com"}',
Expand All @@ -102,6 +104,7 @@ VALUES (
now()
),
(
'28bc7a4e-c095-4573-93dc-e0be29bada97',
'28bc7a4e-c095-4573-93dc-e0be29bada97',
'28bc7a4e-c095-4573-93dc-e0be29bada97',
'{"sub": "28bc7a4e-c095-4573-93dc-e0be29bada97", "email": "fake2@email.com"}',
Expand Down
27 changes: 14 additions & 13 deletions infra/gotrue/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,36 +1,37 @@
# docker-compose.yml
version: "3"
version: '3'
services:
gotrue: # Signup enabled, autoconfirm on
image: supabase/gotrue:v2.40.1
image: supabase/gotrue:v2.129.0
ports:
- "9998:9998"
- '9998:9998'
environment:
GOTRUE_JWT_SECRET: "37c304f8-51aa-419a-a1af-06154e63707a"
GOTRUE_JWT_SECRET: '37c304f8-51aa-419a-a1af-06154e63707a'
GOTRUE_JWT_EXP: 3600
GOTRUE_DB_DRIVER: postgres
DB_NAMESPACE: auth
GOTRUE_API_HOST: 0.0.0.0
PORT: 9998
GOTRUE_DISABLE_SIGNUP: "false"
GOTRUE_DISABLE_SIGNUP: 'false'
API_EXTERNAL_URL: http://localhost:9998
GOTRUE_SITE_URL: http://localhost:9998
GOTRUE_MAILER_AUTOCONFIRM: "true"
GOTRUE_SMS_AUTOCONFIRM: "true"
GOTRUE_MAILER_AUTOCONFIRM: 'true'
GOTRUE_SMS_AUTOCONFIRM: 'true'
GOTRUE_LOG_LEVEL: DEBUG
GOTRUE_OPERATOR_TOKEN: super-secret-operator-token
DATABASE_URL: "postgres://postgres:postgres@db:5432/postgres?sslmode=disable"
GOTRUE_EXTERNAL_PHONE_ENABLED: "true"
GOTRUE_EXTERNAL_GOOGLE_ENABLED: "true"
DATABASE_URL: 'postgres://postgres:postgres@db:5432/postgres?sslmode=disable'
GOTRUE_EXTERNAL_PHONE_ENABLED: 'true'
GOTRUE_EXTERNAL_GOOGLE_ENABLED: 'true'
GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: 53566906701-bmhc1ndue7hild39575gkpimhs06b7ds.apps.googleusercontent.com
GOTRUE_EXTERNAL_GOOGLE_SECRET: Sm3s8RE85rDcS36iMy8YjrpC
GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: http://localhost:9998/callback
GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: 'true'

depends_on:
- db
restart: on-failure
rest:
image: postgrest/postgrest:v10.1.1.20221215
image: postgrest/postgrest:v11.2.2
ports:
- '3000:3000'
environment:
Expand All @@ -43,9 +44,9 @@ services:
depends_on:
- db
db:
image: supabase/postgres
image: supabase/postgres:15.1.0.37
ports:
- "5432:5432"
- '5432:5432'
volumes:
- ./db:/docker-entrypoint-initdb.d/
environment:
Expand Down
2 changes: 1 addition & 1 deletion packages/gotrue/lib/src/fetch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ class GotrueFetch {
qs['redirect_to'] = options!.redirectTo!;
}
Uri uri = Uri.parse(url);
uri = uri.replace(queryParameters: qs);
uri = uri.replace(queryParameters: {...uri.queryParameters, ...qs});
late final http.Response response;

final bodyStr = json.encode(options?.body ?? {});
Expand Down
59 changes: 54 additions & 5 deletions packages/gotrue/lib/src/gotrue_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,9 @@ class GoTrueClient {
Map<String, String>? queryParams,
}) async {
_removeSession();
return _handleProviderSignIn(
return _getUrlForProvider(
provider,
url: '$_url/authorize',
redirectTo: redirectTo,
scopes: scopes,
queryParams: queryParams,
Expand Down Expand Up @@ -794,6 +795,49 @@ class GoTrueClient {
);
}

/// Gets all the identities linked to a user.
Future<List<UserIdentity>> getUserIdentities() async {
final res = await getUser();
return res.user?.identities ?? [];
}

/// Returns the URL to link the user's identity with an OAuth provider.
Future<OAuthResponse> getLinkIdentityUrl(
OAuthProvider provider, {
String? redirectTo,
String? scopes,
Map<String, String>? queryParams,
}) async {
final urlResponse = await _getUrlForProvider(
provider,
url: '$_url/user/identities/authorize',
redirectTo: redirectTo,
scopes: scopes,
queryParams: queryParams,
skipBrowserRedirect: true,
);
final res = await _fetch.request(urlResponse.url, RequestMethodType.get,
options: GotrueRequestOptions(
headers: _headers,
jwt: _currentSession?.accessToken,
));
return OAuthResponse(provider: provider, url: res['url']);
}

/// Unlinks an identity from a user by deleting it.
///
/// The user will no longer be able to sign in with that identity once it's unlinked.
Future<void> unlinkIdentity(UserIdentity identity) async {
await _fetch.request(
'$_url/user/identities/${identity.id}',
RequestMethodType.delete,
options: GotrueRequestOptions(
headers: headers,
jwt: _currentSession?.accessToken,
),
);
}

/// Set the initial session to the session obtained from local storage
Future<void> setInitialSession(String jsonStr) async {
final session = Session.fromJson(json.decode(jsonStr));
Expand Down Expand Up @@ -836,12 +880,14 @@ class GoTrueClient {
}
}

/// return provider url only
Future<OAuthResponse> _handleProviderSignIn(
/// Returns the OAuth sign in URL constructed from the [url] parameter.
Future<OAuthResponse> _getUrlForProvider(
OAuthProvider provider, {
required String url,
required String? scopes,
required String? redirectTo,
required Map<String, String>? queryParams,
bool skipBrowserRedirect = false,
}) async {
final urlParams = {'provider': provider.name};
if (scopes != null) {
Expand Down Expand Up @@ -870,8 +916,11 @@ class GoTrueClient {
};
urlParams.addAll(flowParams);
}
final url = '$_url/authorize?${Uri(queryParameters: urlParams).query}';
return OAuthResponse(provider: provider, url: url);
if (skipBrowserRedirect) {
urlParams['skip_http_redirect'] = 'true';
}
final oauthUrl = '$url?${Uri(queryParameters: urlParams).query}';
return OAuthResponse(provider: provider, url: oauthUrl);
}

void _saveSession(Session session) async {
Expand Down
4 changes: 2 additions & 2 deletions packages/gotrue/lib/src/types/auth_response.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ class AuthResponse {
/// Response of OAuth signin
class OAuthResponse {
final OAuthProvider provider;
final String? url;
final String url;

/// Instanciates an `OAuthResponse` object from json response.
const OAuthResponse({
required this.provider,
this.url,
required this.url,
});
}

Expand Down
10 changes: 9 additions & 1 deletion packages/gotrue/lib/src/types/user.dart
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ class UserIdentity {
final String id;
final String userId;
final Map<String, dynamic>? identityData;
final String identityId;
final String provider;
final String? createdAt;
final String? lastSignInAt;
Expand All @@ -187,6 +188,7 @@ class UserIdentity {
required this.id,
required this.userId,
required this.identityData,
required this.identityId,
required this.provider,
required this.createdAt,
required this.lastSignInAt,
Expand All @@ -197,6 +199,7 @@ class UserIdentity {
String? id,
String? userId,
Map<String, dynamic>? identityData,
String? identityId,
String? provider,
String? createdAt,
String? lastSignInAt,
Expand All @@ -206,6 +209,7 @@ class UserIdentity {
id: id ?? this.id,
userId: userId ?? this.userId,
identityData: identityData ?? this.identityData,
identityId: identityId ?? this.identityId,
provider: provider ?? this.provider,
createdAt: createdAt ?? this.createdAt,
lastSignInAt: lastSignInAt ?? this.lastSignInAt,
Expand All @@ -218,6 +222,7 @@ class UserIdentity {
id: map['id'] as String,
userId: map['user_id'] as String,
identityData: (map['identity_data'] as Map?)?.cast<String, dynamic>(),
identityId: (map['identity_id'] ?? '') as String,
provider: map['provider'] as String,
createdAt: map['created_at'] as String?,
lastSignInAt: map['last_sign_in_at'] as String?,
Expand All @@ -230,6 +235,7 @@ class UserIdentity {
'id': id,
'user_id': userId,
'identity_data': identityData,
'identity_id': identityId,
'provider': provider,
'created_at': createdAt,
'last_sign_in_at': lastSignInAt,
Expand All @@ -239,7 +245,7 @@ class UserIdentity {

@override
String toString() {
return 'UserIdentity(id: $id, userId: $userId, identityData: $identityData, provider: $provider, createdAt: $createdAt, lastSignInAt: $lastSignInAt, updatedAt: $updatedAt)';
return 'UserIdentity(id: $id, userId: $userId, identityData: $identityData, identityId: $identityId, provider: $provider, createdAt: $createdAt, lastSignInAt: $lastSignInAt, updatedAt: $updatedAt)';
}

@override
Expand All @@ -251,6 +257,7 @@ class UserIdentity {
other.id == id &&
other.userId == userId &&
mapEquals(other.identityData, identityData) &&
other.identityId == identityId &&
other.provider == provider &&
other.createdAt == createdAt &&
other.lastSignInAt == lastSignInAt &&
Expand All @@ -262,6 +269,7 @@ class UserIdentity {
return id.hashCode ^
userId.hashCode ^
identityData.hashCode ^
identityId.hashCode ^
provider.hashCode ^
createdAt.hashCode ^
lastSignInAt.hashCode ^
Expand Down
15 changes: 13 additions & 2 deletions packages/gotrue/test/client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ void main() {
provider: OAuthProvider.google, redirectTo: 'https://supabase.com');
final expectedOutput =
'$gotrueUrl/authorize?provider=google&redirect_to=https%3A%2F%2Fsupabase.com';
final queryParameters = Uri.parse(res.url!).queryParameters;
final queryParameters = Uri.parse(res.url).queryParameters;

expect(res.url, startsWith(expectedOutput));
expect(queryParameters, containsPair('flow_type', 'pkce'));
Expand Down Expand Up @@ -406,6 +406,17 @@ void main() {

expect(client.currentSession, isNull);
});

test('Call getLinkIdentityUrl', () async {
await client.signInWithPassword(
email: email1,
password: password,
);
final res = await client.getLinkIdentityUrl(OAuthProvider.google);
expect(res.url, isA<String>());
final uri = Uri.parse(res.url);
expect(uri.host, 'accounts.google.com');
});
});

group('Client with custom http client', () {
Expand Down Expand Up @@ -471,7 +482,7 @@ void main() {
final response = await client.getOAuthSignInUrl(
provider: OAuthProvider.google,
);
final url = Uri.parse(response.url!);
final url = Uri.parse(response.url);
final queryParameters = url.queryParameters;
expect(queryParameters['provider'], 'google');
expect(queryParameters['flow_type'], 'pkce');
Expand Down
37 changes: 36 additions & 1 deletion packages/supabase_flutter/lib/src/supabase_auth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ extension GoTrueClientSignInProvider on GoTrueClient {
scopes: scopes,
queryParams: queryParams,
);
final uri = Uri.parse(res.url!);
final uri = Uri.parse(res.url);

LaunchMode launchMode = authScreenLaunchMode;

Expand All @@ -298,4 +298,39 @@ extension GoTrueClientSignInProvider on GoTrueClient {
final random = Random.secure();
return base64Url.encode(List<int>.generate(16, (_) => random.nextInt(256)));
}

/// Links an oauth identity to an existing user.
/// This method supports the PKCE flow.
Future<bool> linkIdentity(
OAuthProvider provider, {
String? redirectTo,
String? scopes,
LaunchMode authScreenLaunchMode = LaunchMode.platformDefault,
Map<String, String>? queryParams,
}) async {
final res = await getLinkIdentityUrl(
provider,
redirectTo: redirectTo,
scopes: scopes,
queryParams: queryParams,
);
final uri = Uri.parse(res.url);

LaunchMode launchMode = authScreenLaunchMode;

// `Platform.isAndroid` throws on web, so adding a guard for web here.
final isAndroid = !kIsWeb && Platform.isAndroid;

// Google login has to be performed on external browser window on Android
if (provider == OAuthProvider.google && isAndroid) {
launchMode = LaunchMode.externalApplication;
}

final result = await launchUrl(
uri,
mode: launchMode,
webOnlyWindowName: '_self',
);
return result;
}
}

0 comments on commit 6c0c922

Please sign in to comment.