add(conduit): refactoring conduit core
This commit is contained in:
parent
fe9ccd7d7c
commit
bbc1e48740
13 changed files with 2868 additions and 0 deletions
22
packages/auth/lib/auth.dart
Normal file
22
packages/auth/lib/auth.dart
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
/*
|
||||||
|
* This file is part of the Protevus Platform.
|
||||||
|
*
|
||||||
|
* (C) Protevus <developers@protevus.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/auth.dart';
|
||||||
|
export 'src/auth_code_controller.dart';
|
||||||
|
export 'src/auth_controller.dart';
|
||||||
|
export 'src/auth_redirect_controller.dart';
|
||||||
|
export 'src/authorization_parser.dart';
|
||||||
|
export 'src/authorization_server.dart';
|
||||||
|
export 'src/authorizer.dart';
|
||||||
|
export 'src/exceptions.dart';
|
||||||
|
export 'src/objects.dart';
|
||||||
|
export 'src/protocols.dart';
|
||||||
|
export 'src/validator.dart';
|
70
packages/auth/lib/src/auth.dart
Normal file
70
packages/auth/lib/src/auth.dart
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import 'package:protevus_auth/auth.dart';
|
||||||
|
import 'package:protevus_hashing/hashing.dart';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
|
export 'auth_code_controller.dart';
|
||||||
|
export 'auth_controller.dart';
|
||||||
|
export 'auth_redirect_controller.dart';
|
||||||
|
export 'authorization_parser.dart';
|
||||||
|
export 'authorization_server.dart';
|
||||||
|
export 'authorizer.dart';
|
||||||
|
export 'exceptions.dart';
|
||||||
|
export 'objects.dart';
|
||||||
|
export 'protocols.dart';
|
||||||
|
export 'validator.dart';
|
||||||
|
|
||||||
|
/// A utility method to generate a password hash using the PBKDF2 scheme.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
String generatePasswordHash(
|
||||||
|
String password,
|
||||||
|
String salt, {
|
||||||
|
int hashRounds = 1000,
|
||||||
|
int hashLength = 32,
|
||||||
|
Hash? hashFunction,
|
||||||
|
}) {
|
||||||
|
final generator = PBKDF2(hashAlgorithm: hashFunction ?? sha256);
|
||||||
|
return generator.generateBase64Key(password, salt, hashRounds, hashLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A utility method to generate a random base64 salt.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
String generateRandomSalt({int hashLength = 32}) {
|
||||||
|
return generateAsBase64String(hashLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A utility method to generate a ClientID and Client Secret Pair.
|
||||||
|
///
|
||||||
|
/// [secret] may be null. If secret is null, the return value is a 'public' client. Otherwise, the
|
||||||
|
/// client is 'confidential'. Public clients must not include a client secret when sent to the
|
||||||
|
/// authorization server. Confidential clients must include the secret in all requests. Use public clients when
|
||||||
|
/// the source code of the client application is visible, i.e. a JavaScript browser application.
|
||||||
|
///
|
||||||
|
/// Any client that allows the authorization code flow must include [redirectURI].
|
||||||
|
///
|
||||||
|
/// Note that [secret] is hashed with a randomly generated salt, and therefore cannot be retrieved
|
||||||
|
/// later. The plain-text secret must be stored securely elsewhere.
|
||||||
|
AuthClient generateAPICredentialPair(
|
||||||
|
String clientID,
|
||||||
|
String? secret, {
|
||||||
|
String? redirectURI,
|
||||||
|
int hashLength = 32,
|
||||||
|
int hashRounds = 1000,
|
||||||
|
Hash? hashFunction,
|
||||||
|
}) {
|
||||||
|
if (secret == null) {
|
||||||
|
return AuthClient.public(clientID, redirectURI: redirectURI);
|
||||||
|
}
|
||||||
|
|
||||||
|
final salt = generateRandomSalt(hashLength: hashLength);
|
||||||
|
final hashed = generatePasswordHash(
|
||||||
|
secret,
|
||||||
|
salt,
|
||||||
|
hashRounds: hashRounds,
|
||||||
|
hashLength: hashLength,
|
||||||
|
hashFunction: hashFunction,
|
||||||
|
);
|
||||||
|
|
||||||
|
return AuthClient.withRedirectURI(clientID, hashed, salt, redirectURI);
|
||||||
|
}
|
298
packages/auth/lib/src/auth_code_controller.dart
Normal file
298
packages/auth/lib/src/auth_code_controller.dart
Normal file
|
@ -0,0 +1,298 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:protevus_openapi/documentable.dart';
|
||||||
|
import 'package:protevus_auth/auth.dart';
|
||||||
|
import 'package:protevus_http/http.dart';
|
||||||
|
import 'package:protevus_openapi/v3.dart';
|
||||||
|
|
||||||
|
/// Provides [AuthCodeController] with application-specific behavior.
|
||||||
|
@Deprecated('AuthCodeController is deprecated. See docs.')
|
||||||
|
abstract class AuthCodeControllerDelegate {
|
||||||
|
/// Returns an HTML representation of a login form.
|
||||||
|
///
|
||||||
|
/// Invoked when [AuthCodeController.getAuthorizationPage] is called in response to a GET request.
|
||||||
|
/// Must provide HTML that will be returned to the browser for rendering. This form submission of this page
|
||||||
|
/// should be a POST to [requestUri].
|
||||||
|
///
|
||||||
|
/// The form submission should include the values of [responseType], [clientID], [state], [scope]
|
||||||
|
/// as well as user-entered username and password in `x-www-form-urlencoded` data, e.g.
|
||||||
|
///
|
||||||
|
/// POST https://example.com/auth/code
|
||||||
|
/// Content-Type: application/x-www-form-urlencoded
|
||||||
|
///
|
||||||
|
/// response_type=code&client_id=com.conduit.app&state=o9u3jla&username=bob&password=password
|
||||||
|
///
|
||||||
|
///
|
||||||
|
/// If not null, [scope] should also be included as an additional form parameter.
|
||||||
|
Future<String?> render(
|
||||||
|
AuthCodeController forController,
|
||||||
|
Uri requestUri,
|
||||||
|
String? responseType,
|
||||||
|
String clientID,
|
||||||
|
String? state,
|
||||||
|
String? scope,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controller for issuing OAuth 2.0 authorization codes.
|
||||||
|
///
|
||||||
|
/// Deprecated, use [AuthRedirectController] instead.
|
||||||
|
///
|
||||||
|
/// This controller provides an endpoint for the creating an OAuth 2.0 authorization code. This authorization code
|
||||||
|
/// can be exchanged for an access token with an [AuthController]. This is known as the OAuth 2.0 'Authorization Code Grant' flow.
|
||||||
|
///
|
||||||
|
/// See operation methods [getAuthorizationPage] and [authorize] for more details.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
///
|
||||||
|
/// router
|
||||||
|
/// .route("/auth/code")
|
||||||
|
/// .link(() => new AuthCodeController(authServer));
|
||||||
|
///
|
||||||
|
@Deprecated('Use AuthRedirectController instead.')
|
||||||
|
class AuthCodeController extends ResourceController {
|
||||||
|
/// Creates a new instance of an [AuthCodeController].
|
||||||
|
///
|
||||||
|
/// [authServer] is the required authorization server. If [delegate] is provided, this controller will return a login page for all GET requests.
|
||||||
|
@Deprecated('Use AuthRedirectController instead.')
|
||||||
|
AuthCodeController(this.authServer, {this.delegate}) {
|
||||||
|
acceptedContentTypes = [
|
||||||
|
ContentType("application", "x-www-form-urlencoded")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reference to the [AuthServer] used to grant authorization codes.
|
||||||
|
final AuthServer authServer;
|
||||||
|
|
||||||
|
/// A randomly generated value the client can use to verify the origin of the redirect.
|
||||||
|
///
|
||||||
|
/// Clients must include this query parameter and verify that any redirects from this
|
||||||
|
/// server have the same value for 'state' as passed in. This value is usually a randomly generated
|
||||||
|
/// session identifier.
|
||||||
|
@Bind.query("state")
|
||||||
|
String? state;
|
||||||
|
|
||||||
|
/// Must be 'code'.
|
||||||
|
@Bind.query("response_type")
|
||||||
|
String? responseType;
|
||||||
|
|
||||||
|
/// The client ID of the authenticating client.
|
||||||
|
///
|
||||||
|
/// This must be a valid client ID according to [authServer].\
|
||||||
|
@Bind.query("client_id")
|
||||||
|
String? clientID;
|
||||||
|
|
||||||
|
/// Renders an HTML login form.
|
||||||
|
final AuthCodeControllerDelegate? delegate;
|
||||||
|
|
||||||
|
/// Returns an HTML login form.
|
||||||
|
///
|
||||||
|
/// A client that wishes to authenticate with this server should direct the user
|
||||||
|
/// to this page. The user will enter their username and password that is sent as a POST
|
||||||
|
/// request to this same controller.
|
||||||
|
///
|
||||||
|
/// The 'client_id' must be a registered, valid client of this server. The client must also provide
|
||||||
|
/// a [state] to this request and verify that the redirect contains the same value in its query string.
|
||||||
|
@Operation.get()
|
||||||
|
Future<Response> getAuthorizationPage({
|
||||||
|
/// A space-delimited list of access scopes to be requested by the form submission on the returned page.
|
||||||
|
@Bind.query("scope") String? scope,
|
||||||
|
}) async {
|
||||||
|
if (clientID == null) {
|
||||||
|
return Response.badRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delegate == null) {
|
||||||
|
return Response(405, {}, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
final renderedPage = await delegate!
|
||||||
|
.render(this, request!.raw.uri, responseType, clientID!, state, scope);
|
||||||
|
|
||||||
|
return Response.ok(renderedPage)..contentType = ContentType.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a one-time use authorization code.
|
||||||
|
///
|
||||||
|
/// This method will respond with a redirect that contains an authorization code ('code')
|
||||||
|
/// and the passed in 'state'. If this request fails, the redirect URL
|
||||||
|
/// will contain an 'error' key instead of the authorization code.
|
||||||
|
///
|
||||||
|
/// This method is typically invoked by the login form returned from the GET to this controller.
|
||||||
|
@Operation.post()
|
||||||
|
Future<Response> authorize({
|
||||||
|
/// The username of the authenticating user.
|
||||||
|
@Bind.query("username") String? username,
|
||||||
|
|
||||||
|
/// The password of the authenticating user.
|
||||||
|
@Bind.query("password") String? password,
|
||||||
|
|
||||||
|
/// A space-delimited list of access scopes being requested.
|
||||||
|
@Bind.query("scope") String? scope,
|
||||||
|
}) async {
|
||||||
|
final client = await authServer.getClient(clientID!);
|
||||||
|
|
||||||
|
if (state == null) {
|
||||||
|
return _redirectResponse(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
error: AuthServerException(AuthRequestError.invalidRequest, client),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseType != "code") {
|
||||||
|
if (client?.redirectURI == null) {
|
||||||
|
return Response.badRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _redirectResponse(
|
||||||
|
null,
|
||||||
|
state,
|
||||||
|
error: AuthServerException(AuthRequestError.invalidRequest, client),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final scopes = scope?.split(" ").map((s) => AuthScope(s)).toList();
|
||||||
|
|
||||||
|
final authCode = await authServer.authenticateForCode(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
clientID!,
|
||||||
|
requestedScopes: scopes,
|
||||||
|
);
|
||||||
|
return _redirectResponse(client!.redirectURI, state, code: authCode.code);
|
||||||
|
} on FormatException {
|
||||||
|
return _redirectResponse(
|
||||||
|
null,
|
||||||
|
state,
|
||||||
|
error: AuthServerException(AuthRequestError.invalidScope, client),
|
||||||
|
);
|
||||||
|
} on AuthServerException catch (e) {
|
||||||
|
return _redirectResponse(null, state, error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
APIRequestBody? documentOperationRequestBody(
|
||||||
|
APIDocumentContext context,
|
||||||
|
Operation? operation,
|
||||||
|
) {
|
||||||
|
final body = super.documentOperationRequestBody(context, operation);
|
||||||
|
if (operation!.method == "POST") {
|
||||||
|
body!.content!["application/x-www-form-urlencoded"]!.schema!
|
||||||
|
.properties!["password"]!.format = "password";
|
||||||
|
body.content!["application/x-www-form-urlencoded"]!.schema!.isRequired = [
|
||||||
|
"client_id",
|
||||||
|
"state",
|
||||||
|
"response_type",
|
||||||
|
"username",
|
||||||
|
"password"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<APIParameter> documentOperationParameters(
|
||||||
|
APIDocumentContext context,
|
||||||
|
Operation? operation,
|
||||||
|
) {
|
||||||
|
final params = super.documentOperationParameters(context, operation)!;
|
||||||
|
params.where((p) => p.name != "scope").forEach((p) {
|
||||||
|
p.isRequired = true;
|
||||||
|
});
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, APIResponse> documentOperationResponses(
|
||||||
|
APIDocumentContext context,
|
||||||
|
Operation? operation,
|
||||||
|
) {
|
||||||
|
if (operation!.method == "GET") {
|
||||||
|
return {
|
||||||
|
"200": APIResponse.schema(
|
||||||
|
"Serves a login form.",
|
||||||
|
APISchemaObject.string(),
|
||||||
|
contentTypes: ["text/html"],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
} else if (operation.method == "POST") {
|
||||||
|
return {
|
||||||
|
"${HttpStatus.movedTemporarily}": APIResponse(
|
||||||
|
"If successful, the query parameter of the redirect URI named 'code' contains authorization code. "
|
||||||
|
"Otherwise, the query parameter 'error' is present and contains a error string.",
|
||||||
|
headers: {
|
||||||
|
"Location": APIHeader()
|
||||||
|
..schema = APISchemaObject.string(format: "uri")
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"${HttpStatus.badRequest}": APIResponse.schema(
|
||||||
|
"If 'client_id' is invalid, the redirect URI cannot be verified and this response is sent.",
|
||||||
|
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||||
|
contentTypes: ["application/json"],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw StateError("AuthCodeController documentation failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, APIOperation> documentOperations(
|
||||||
|
APIDocumentContext context,
|
||||||
|
String route,
|
||||||
|
APIPath path,
|
||||||
|
) {
|
||||||
|
final ops = super.documentOperations(context, route, path);
|
||||||
|
authServer.documentedAuthorizationCodeFlow.authorizationURL =
|
||||||
|
Uri(path: route.substring(1));
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Response _redirectResponse(
|
||||||
|
String? inputUri,
|
||||||
|
String? clientStateOrNull, {
|
||||||
|
String? code,
|
||||||
|
AuthServerException? error,
|
||||||
|
}) {
|
||||||
|
final uriString = inputUri ?? error!.client?.redirectURI;
|
||||||
|
if (uriString == null) {
|
||||||
|
return Response.badRequest(body: {"error": error!.reasonString});
|
||||||
|
}
|
||||||
|
|
||||||
|
final redirectURI = Uri.parse(uriString);
|
||||||
|
final queryParameters =
|
||||||
|
Map<String, String?>.from(redirectURI.queryParameters);
|
||||||
|
|
||||||
|
if (code != null) {
|
||||||
|
queryParameters["code"] = code;
|
||||||
|
}
|
||||||
|
if (clientStateOrNull != null) {
|
||||||
|
queryParameters["state"] = clientStateOrNull;
|
||||||
|
}
|
||||||
|
if (error != null) {
|
||||||
|
queryParameters["error"] = error.reasonString;
|
||||||
|
}
|
||||||
|
|
||||||
|
final responseURI = Uri(
|
||||||
|
scheme: redirectURI.scheme,
|
||||||
|
userInfo: redirectURI.userInfo,
|
||||||
|
host: redirectURI.host,
|
||||||
|
port: redirectURI.port,
|
||||||
|
path: redirectURI.path,
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
);
|
||||||
|
return Response(
|
||||||
|
HttpStatus.movedTemporarily,
|
||||||
|
{
|
||||||
|
HttpHeaders.locationHeader: responseURI.toString(),
|
||||||
|
HttpHeaders.cacheControlHeader: "no-store",
|
||||||
|
HttpHeaders.pragmaHeader: "no-cache"
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
226
packages/auth/lib/src/auth_controller.dart
Normal file
226
packages/auth/lib/src/auth_controller.dart
Normal file
|
@ -0,0 +1,226 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:protevus_openapi/documentable.dart';
|
||||||
|
import 'package:protevus_auth/auth.dart';
|
||||||
|
import 'package:protevus_http/http.dart';
|
||||||
|
import 'package:protevus_openapi/v3.dart';
|
||||||
|
|
||||||
|
/// Controller for issuing and refreshing OAuth 2.0 access tokens.
|
||||||
|
///
|
||||||
|
/// This controller issues and refreshes access tokens. Access tokens are issued for valid username and password (resource owner password grant)
|
||||||
|
/// or for an authorization code (authorization code grant) from a [AuthRedirectController].
|
||||||
|
///
|
||||||
|
/// See operation method [grant] for more details.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
///
|
||||||
|
/// router
|
||||||
|
/// .route("/auth/token")
|
||||||
|
/// .link(() => new AuthController(authServer));
|
||||||
|
///
|
||||||
|
class AuthController extends ResourceController {
|
||||||
|
/// Creates a new instance of an [AuthController].
|
||||||
|
///
|
||||||
|
/// [authServer] is the isRequired authorization server that grants tokens.
|
||||||
|
AuthController(this.authServer) {
|
||||||
|
acceptedContentTypes = [
|
||||||
|
ContentType("application", "x-www-form-urlencoded")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reference to the [AuthServer] this controller uses to grant tokens.
|
||||||
|
final AuthServer authServer;
|
||||||
|
|
||||||
|
/// Required basic authentication Authorization header containing client ID and secret for the authenticating client.
|
||||||
|
///
|
||||||
|
/// Requests must contain the client ID and client secret in the authorization header,
|
||||||
|
/// using the basic authentication scheme. If the client is a public client - i.e., no client secret -
|
||||||
|
/// the client secret is omitted from the Authorization header.
|
||||||
|
///
|
||||||
|
/// Example: com.stablekernel.public is a public client. The Authorization header should be constructed
|
||||||
|
/// as so:
|
||||||
|
///
|
||||||
|
/// Authorization: Basic base64("com.stablekernel.public:")
|
||||||
|
///
|
||||||
|
/// Notice the trailing colon indicates that the client secret is the empty string.
|
||||||
|
@Bind.header(HttpHeaders.authorizationHeader)
|
||||||
|
String? authHeader;
|
||||||
|
|
||||||
|
final AuthorizationBasicParser _parser = const AuthorizationBasicParser();
|
||||||
|
|
||||||
|
/// Creates or refreshes an authentication token.
|
||||||
|
///
|
||||||
|
/// When grant_type is 'password', there must be username and password values.
|
||||||
|
/// When grant_type is 'refresh_token', there must be a refresh_token value.
|
||||||
|
/// When grant_type is 'authorization_code', there must be a authorization_code value.
|
||||||
|
///
|
||||||
|
/// This endpoint requires client_id authentication. The Authorization header must
|
||||||
|
/// include a valid Client ID and Secret in the Basic authorization scheme format.
|
||||||
|
@Operation.post()
|
||||||
|
Future<Response> grant({
|
||||||
|
@Bind.query("username") String? username,
|
||||||
|
@Bind.query("password") String? password,
|
||||||
|
@Bind.query("refresh_token") String? refreshToken,
|
||||||
|
@Bind.query("code") String? authCode,
|
||||||
|
@Bind.query("grant_type") String? grantType,
|
||||||
|
@Bind.query("scope") String? scope,
|
||||||
|
}) async {
|
||||||
|
AuthBasicCredentials basicRecord;
|
||||||
|
try {
|
||||||
|
basicRecord = _parser.parse(authHeader);
|
||||||
|
} on AuthorizationParserException {
|
||||||
|
return _responseForError(AuthRequestError.invalidClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final scopes = scope?.split(" ").map((s) => AuthScope(s)).toList();
|
||||||
|
|
||||||
|
if (grantType == "password") {
|
||||||
|
final token = await authServer.authenticate(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
basicRecord.username,
|
||||||
|
basicRecord.password,
|
||||||
|
requestedScopes: scopes,
|
||||||
|
);
|
||||||
|
|
||||||
|
return AuthController.tokenResponse(token);
|
||||||
|
} else if (grantType == "refresh_token") {
|
||||||
|
final token = await authServer.refresh(
|
||||||
|
refreshToken,
|
||||||
|
basicRecord.username,
|
||||||
|
basicRecord.password,
|
||||||
|
requestedScopes: scopes,
|
||||||
|
);
|
||||||
|
|
||||||
|
return AuthController.tokenResponse(token);
|
||||||
|
} else if (grantType == "authorization_code") {
|
||||||
|
if (scope != null) {
|
||||||
|
return _responseForError(AuthRequestError.invalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
final token = await authServer.exchange(
|
||||||
|
authCode, basicRecord.username, basicRecord.password);
|
||||||
|
|
||||||
|
return AuthController.tokenResponse(token);
|
||||||
|
} else if (grantType == null) {
|
||||||
|
return _responseForError(AuthRequestError.invalidRequest);
|
||||||
|
}
|
||||||
|
} on FormatException {
|
||||||
|
return _responseForError(AuthRequestError.invalidScope);
|
||||||
|
} on AuthServerException catch (e) {
|
||||||
|
return _responseForError(e.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _responseForError(AuthRequestError.unsupportedGrantType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transforms a [AuthToken] into a [Response] object with an RFC6749 compliant JSON token
|
||||||
|
/// as the HTTP response body.
|
||||||
|
static Response tokenResponse(AuthToken token) {
|
||||||
|
return Response(
|
||||||
|
HttpStatus.ok,
|
||||||
|
{"Cache-Control": "no-store", "Pragma": "no-cache"},
|
||||||
|
token.asMap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void willSendResponse(Response response) {
|
||||||
|
if (response.statusCode == 400) {
|
||||||
|
// This post-processes the response in the case that duplicate parameters
|
||||||
|
// were in the request, which violates oauth2 spec. It just adjusts the error message.
|
||||||
|
// This could be hardened some.
|
||||||
|
final body = response.body;
|
||||||
|
if (body != null && body["error"] is String) {
|
||||||
|
final errorMessage = body["error"] as String;
|
||||||
|
if (errorMessage.startsWith("multiple values")) {
|
||||||
|
response.body = {
|
||||||
|
"error":
|
||||||
|
AuthServerException.errorString(AuthRequestError.invalidRequest)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<APIParameter> documentOperationParameters(
|
||||||
|
APIDocumentContext context,
|
||||||
|
Operation? operation,
|
||||||
|
) {
|
||||||
|
final parameters = super.documentOperationParameters(context, operation)!;
|
||||||
|
parameters.removeWhere((p) => p.name == HttpHeaders.authorizationHeader);
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
APIRequestBody documentOperationRequestBody(
|
||||||
|
APIDocumentContext context,
|
||||||
|
Operation? operation,
|
||||||
|
) {
|
||||||
|
final body = super.documentOperationRequestBody(context, operation)!;
|
||||||
|
body.content!["application/x-www-form-urlencoded"]!.schema!.isRequired = [
|
||||||
|
"grant_type"
|
||||||
|
];
|
||||||
|
body.content!["application/x-www-form-urlencoded"]!.schema!
|
||||||
|
.properties!["password"]!.format = "password";
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, APIOperation> documentOperations(
|
||||||
|
APIDocumentContext context,
|
||||||
|
String route,
|
||||||
|
APIPath path,
|
||||||
|
) {
|
||||||
|
final operations = super.documentOperations(context, route, path);
|
||||||
|
|
||||||
|
operations.forEach((_, op) {
|
||||||
|
op.security = [
|
||||||
|
APISecurityRequirement({"oauth2-client-authentication": []})
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
final relativeUri = Uri(path: route.substring(1));
|
||||||
|
authServer.documentedAuthorizationCodeFlow.tokenURL = relativeUri;
|
||||||
|
authServer.documentedAuthorizationCodeFlow.refreshURL = relativeUri;
|
||||||
|
|
||||||
|
authServer.documentedPasswordFlow.tokenURL = relativeUri;
|
||||||
|
authServer.documentedPasswordFlow.refreshURL = relativeUri;
|
||||||
|
|
||||||
|
return operations;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, APIResponse> documentOperationResponses(
|
||||||
|
APIDocumentContext context,
|
||||||
|
Operation? operation,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
"200": APIResponse.schema(
|
||||||
|
"Successfully exchanged credentials for token",
|
||||||
|
APISchemaObject.object({
|
||||||
|
"access_token": APISchemaObject.string(),
|
||||||
|
"token_type": APISchemaObject.string(),
|
||||||
|
"expires_in": APISchemaObject.integer(),
|
||||||
|
"refresh_token": APISchemaObject.string(),
|
||||||
|
"scope": APISchemaObject.string()
|
||||||
|
}),
|
||||||
|
contentTypes: ["application/json"],
|
||||||
|
),
|
||||||
|
"400": APIResponse.schema(
|
||||||
|
"Invalid credentials or missing parameters.",
|
||||||
|
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||||
|
contentTypes: ["application/json"],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Response _responseForError(AuthRequestError error) {
|
||||||
|
return Response.badRequest(
|
||||||
|
body: {"error": AuthServerException.errorString(error)},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
384
packages/auth/lib/src/auth_redirect_controller.dart
Normal file
384
packages/auth/lib/src/auth_redirect_controller.dart
Normal file
|
@ -0,0 +1,384 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:protevus_openapi/documentable.dart';
|
||||||
|
import 'package:protevus_auth/auth.dart';
|
||||||
|
import 'package:protevus_http/http.dart';
|
||||||
|
import 'package:protevus_openapi/v3.dart';
|
||||||
|
|
||||||
|
/// Provides [AuthRedirectController] with application-specific behavior.
|
||||||
|
abstract class AuthRedirectControllerDelegate {
|
||||||
|
/// Returns an HTML representation of a login form.
|
||||||
|
///
|
||||||
|
/// Invoked when [AuthRedirectController.getAuthorizationPage] is called in response to a GET request.
|
||||||
|
/// Must provide HTML that will be returned to the browser for rendering. This form submission of this page
|
||||||
|
/// should be a POST to [requestUri].
|
||||||
|
///
|
||||||
|
/// The form submission should include the values of [responseType], [clientID], [state], [scope]
|
||||||
|
/// as well as user-entered username and password in `x-www-form-urlencoded` data, e.g.
|
||||||
|
///
|
||||||
|
/// POST https://example.com/auth/code
|
||||||
|
/// Content-Type: application/x-www-form-urlencoded
|
||||||
|
///
|
||||||
|
/// response_type=code&client_id=com.conduit.app&state=o9u3jla&username=bob&password=password
|
||||||
|
///
|
||||||
|
///
|
||||||
|
/// If not null, [scope] should also be included as an additional form parameter.
|
||||||
|
Future<String?> render(
|
||||||
|
AuthRedirectController forController,
|
||||||
|
Uri requestUri,
|
||||||
|
String? responseType,
|
||||||
|
String clientID,
|
||||||
|
String? state,
|
||||||
|
String? scope,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controller for issuing OAuth 2.0 authorization codes and tokens.
|
||||||
|
///
|
||||||
|
/// This controller provides an endpoint for creating an OAuth 2.0 authorization code or access token. An authorization code
|
||||||
|
/// can be exchanged for an access token with an [AuthController]. This is known as the OAuth 2.0 'Authorization Code Grant' flow.
|
||||||
|
/// Returning an access token is known as the OAuth 2.0 'Implicit Grant' flow.
|
||||||
|
///
|
||||||
|
/// See operation methods [getAuthorizationPage] and [authorize] for more details.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
///
|
||||||
|
/// router
|
||||||
|
/// .route("/auth/code")
|
||||||
|
/// .link(() => new AuthRedirectController(authServer));
|
||||||
|
///
|
||||||
|
class AuthRedirectController extends ResourceController {
|
||||||
|
/// Creates a new instance of an [AuthRedirectController].
|
||||||
|
///
|
||||||
|
/// [authServer] is the required authorization server. If [delegate] is provided, this controller will return a login page for all GET requests.
|
||||||
|
AuthRedirectController(
|
||||||
|
this.authServer, {
|
||||||
|
this.delegate,
|
||||||
|
this.allowsImplicit = true,
|
||||||
|
}) {
|
||||||
|
acceptedContentTypes = [
|
||||||
|
ContentType("application", "x-www-form-urlencoded")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static final Response _unsupportedResponseTypeResponse = Response.badRequest(
|
||||||
|
body: "<h1>Error</h1><p>unsupported_response_type</p>",
|
||||||
|
)..contentType = ContentType.html;
|
||||||
|
|
||||||
|
/// A reference to the [AuthServer] used to grant authorization codes and access tokens.
|
||||||
|
late final AuthServer authServer;
|
||||||
|
|
||||||
|
/// When true, the controller allows for the Implicit Grant Flow
|
||||||
|
final bool allowsImplicit;
|
||||||
|
|
||||||
|
/// A randomly generated value the client can use to verify the origin of the redirect.
|
||||||
|
///
|
||||||
|
/// Clients must include this query parameter and verify that any redirects from this
|
||||||
|
/// server have the same value for 'state' as passed in. This value is usually a randomly generated
|
||||||
|
/// session identifier.
|
||||||
|
@Bind.query("state")
|
||||||
|
String? state;
|
||||||
|
|
||||||
|
/// Must be 'code' or 'token'.
|
||||||
|
@Bind.query("response_type")
|
||||||
|
String? responseType;
|
||||||
|
|
||||||
|
/// The client ID of the authenticating client.
|
||||||
|
///
|
||||||
|
/// This must be a valid client ID according to [authServer].\
|
||||||
|
@Bind.query("client_id")
|
||||||
|
String? clientID;
|
||||||
|
|
||||||
|
/// Renders an HTML login form.
|
||||||
|
final AuthRedirectControllerDelegate? delegate;
|
||||||
|
|
||||||
|
/// Returns an HTML login form.
|
||||||
|
///
|
||||||
|
/// A client that wishes to authenticate with this server should direct the user
|
||||||
|
/// to this page. The user will enter their username and password that is sent as a POST
|
||||||
|
/// request to this same controller.
|
||||||
|
///
|
||||||
|
/// The 'client_id' must be a registered, valid client of this server. The client must also provide
|
||||||
|
/// a [state] to this request and verify that the redirect contains the same value in its query string.
|
||||||
|
@Operation.get()
|
||||||
|
Future<Response> getAuthorizationPage({
|
||||||
|
/// A space-delimited list of access scopes to be requested by the form submission on the returned page.
|
||||||
|
@Bind.query("scope") String? scope,
|
||||||
|
}) async {
|
||||||
|
if (delegate == null) {
|
||||||
|
return Response(405, {}, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseType != "code" && responseType != "token") {
|
||||||
|
return _unsupportedResponseTypeResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseType == "token" && !allowsImplicit) {
|
||||||
|
return _unsupportedResponseTypeResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
final renderedPage = await delegate!
|
||||||
|
.render(this, request!.raw.uri, responseType, clientID!, state, scope);
|
||||||
|
if (renderedPage == null) {
|
||||||
|
return Response.notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.ok(renderedPage)..contentType = ContentType.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a one-time use authorization code or an access token.
|
||||||
|
///
|
||||||
|
/// This method will respond with a redirect that either contains an authorization code ('code')
|
||||||
|
/// or an access token ('token') along with the passed in 'state'. If this request fails,
|
||||||
|
/// the redirect URL will contain an 'error' instead of the authorization code or access token.
|
||||||
|
///
|
||||||
|
/// This method is typically invoked by the login form returned from the GET to this controller.
|
||||||
|
@Operation.post()
|
||||||
|
Future<Response> authorize({
|
||||||
|
/// The username of the authenticating user.
|
||||||
|
@Bind.query("username") String? username,
|
||||||
|
|
||||||
|
/// The password of the authenticating user.
|
||||||
|
@Bind.query("password") String? password,
|
||||||
|
|
||||||
|
/// A space-delimited list of access scopes being requested.
|
||||||
|
@Bind.query("scope") String? scope,
|
||||||
|
}) async {
|
||||||
|
if (clientID == null) {
|
||||||
|
return Response.badRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = await authServer.getClient(clientID!);
|
||||||
|
|
||||||
|
if (client?.redirectURI == null) {
|
||||||
|
return Response.badRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseType == "token" && !allowsImplicit) {
|
||||||
|
return _unsupportedResponseTypeResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == null) {
|
||||||
|
return _redirectResponse(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
error: AuthServerException(AuthRequestError.invalidRequest, client),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final scopes = scope?.split(" ").map((s) => AuthScope(s)).toList();
|
||||||
|
|
||||||
|
if (responseType == "code") {
|
||||||
|
if (client!.hashedSecret == null) {
|
||||||
|
return _redirectResponse(
|
||||||
|
null,
|
||||||
|
state,
|
||||||
|
error: AuthServerException(
|
||||||
|
AuthRequestError.unauthorizedClient,
|
||||||
|
client,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final authCode = await authServer.authenticateForCode(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
clientID!,
|
||||||
|
requestedScopes: scopes,
|
||||||
|
);
|
||||||
|
return _redirectResponse(
|
||||||
|
client.redirectURI,
|
||||||
|
state,
|
||||||
|
code: authCode.code,
|
||||||
|
);
|
||||||
|
} else if (responseType == "token") {
|
||||||
|
final token = await authServer.authenticate(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
clientID!,
|
||||||
|
null,
|
||||||
|
requestedScopes: scopes,
|
||||||
|
);
|
||||||
|
return _redirectResponse(client!.redirectURI, state, token: token);
|
||||||
|
} else {
|
||||||
|
return _redirectResponse(
|
||||||
|
null,
|
||||||
|
state,
|
||||||
|
error: AuthServerException(AuthRequestError.invalidRequest, client),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on FormatException {
|
||||||
|
return _redirectResponse(
|
||||||
|
null,
|
||||||
|
state,
|
||||||
|
error: AuthServerException(AuthRequestError.invalidScope, client),
|
||||||
|
);
|
||||||
|
} on AuthServerException catch (e) {
|
||||||
|
if (responseType == "token" &&
|
||||||
|
e.reason == AuthRequestError.invalidGrant) {
|
||||||
|
return _redirectResponse(
|
||||||
|
null,
|
||||||
|
state,
|
||||||
|
error: AuthServerException(AuthRequestError.accessDenied, client),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _redirectResponse(null, state, error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
APIRequestBody? documentOperationRequestBody(
|
||||||
|
APIDocumentContext context,
|
||||||
|
Operation? operation,
|
||||||
|
) {
|
||||||
|
final body = super.documentOperationRequestBody(context, operation);
|
||||||
|
if (operation!.method == "POST") {
|
||||||
|
body!.content!["application/x-www-form-urlencoded"]!.schema!
|
||||||
|
.properties!["password"]!.format = "password";
|
||||||
|
body.content!["application/x-www-form-urlencoded"]!.schema!.isRequired = [
|
||||||
|
"client_id",
|
||||||
|
"state",
|
||||||
|
"response_type",
|
||||||
|
"username",
|
||||||
|
"password"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<APIParameter> documentOperationParameters(
|
||||||
|
APIDocumentContext context,
|
||||||
|
Operation? operation,
|
||||||
|
) {
|
||||||
|
final params = super.documentOperationParameters(context, operation)!;
|
||||||
|
params.where((p) => p.name != "scope").forEach((p) {
|
||||||
|
p.isRequired = true;
|
||||||
|
});
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, APIResponse> documentOperationResponses(
|
||||||
|
APIDocumentContext context,
|
||||||
|
Operation? operation,
|
||||||
|
) {
|
||||||
|
if (operation!.method == "GET") {
|
||||||
|
return {
|
||||||
|
"200": APIResponse.schema(
|
||||||
|
"Serves a login form.",
|
||||||
|
APISchemaObject.string(),
|
||||||
|
contentTypes: ["text/html"],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
} else if (operation.method == "POST") {
|
||||||
|
return {
|
||||||
|
"${HttpStatus.movedTemporarily}": APIResponse(
|
||||||
|
"If successful, in the case of a 'response type' of 'code', the query "
|
||||||
|
"parameter of the redirect URI named 'code' contains authorization code. "
|
||||||
|
"Otherwise, the query parameter 'error' is present and contains a error string. "
|
||||||
|
"In the case of a 'response type' of 'token', the redirect URI's fragment "
|
||||||
|
"contains an access token. Otherwise, the fragment contains an error code.",
|
||||||
|
headers: {
|
||||||
|
"Location": APIHeader()
|
||||||
|
..schema = APISchemaObject.string(format: "uri")
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"${HttpStatus.badRequest}": APIResponse.schema(
|
||||||
|
"If 'client_id' is invalid, the redirect URI cannot be verified and this response is sent.",
|
||||||
|
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||||
|
contentTypes: ["application/json"],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw StateError("AuthRedirectController documentation failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, APIOperation> documentOperations(
|
||||||
|
APIDocumentContext context,
|
||||||
|
String route,
|
||||||
|
APIPath path,
|
||||||
|
) {
|
||||||
|
final ops = super.documentOperations(context, route, path);
|
||||||
|
final uri = Uri(path: route.substring(1));
|
||||||
|
authServer.documentedAuthorizationCodeFlow.authorizationURL = uri;
|
||||||
|
authServer.documentedImplicitFlow.authorizationURL = uri;
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response _redirectResponse(
|
||||||
|
String? inputUri,
|
||||||
|
String? clientStateOrNull, {
|
||||||
|
String? code,
|
||||||
|
AuthToken? token,
|
||||||
|
AuthServerException? error,
|
||||||
|
}) {
|
||||||
|
final uriString = inputUri ?? error!.client?.redirectURI;
|
||||||
|
if (uriString == null) {
|
||||||
|
return Response.badRequest(body: {"error": error!.reasonString});
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri redirectURI;
|
||||||
|
|
||||||
|
try {
|
||||||
|
redirectURI = Uri.parse(uriString);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.badRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
final queryParameters =
|
||||||
|
Map<String, String>.from(redirectURI.queryParameters);
|
||||||
|
String? fragment;
|
||||||
|
|
||||||
|
if (responseType == "code") {
|
||||||
|
if (code != null) {
|
||||||
|
queryParameters["code"] = code;
|
||||||
|
}
|
||||||
|
if (clientStateOrNull != null) {
|
||||||
|
queryParameters["state"] = clientStateOrNull;
|
||||||
|
}
|
||||||
|
if (error != null) {
|
||||||
|
queryParameters["error"] = error.reasonString;
|
||||||
|
}
|
||||||
|
} else if (responseType == "token") {
|
||||||
|
final params = token?.asMap() ?? {};
|
||||||
|
|
||||||
|
if (clientStateOrNull != null) {
|
||||||
|
params["state"] = clientStateOrNull;
|
||||||
|
}
|
||||||
|
if (error != null) {
|
||||||
|
params["error"] = error.reasonString;
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment = params.keys
|
||||||
|
.map((key) => "$key=${Uri.encodeComponent(params[key].toString())}")
|
||||||
|
.join("&");
|
||||||
|
} else {
|
||||||
|
return _unsupportedResponseTypeResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
final responseURI = Uri(
|
||||||
|
scheme: redirectURI.scheme,
|
||||||
|
userInfo: redirectURI.userInfo,
|
||||||
|
host: redirectURI.host,
|
||||||
|
port: redirectURI.port,
|
||||||
|
path: redirectURI.path,
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
fragment: fragment,
|
||||||
|
);
|
||||||
|
return Response(
|
||||||
|
HttpStatus.movedTemporarily,
|
||||||
|
{
|
||||||
|
HttpHeaders.locationHeader: responseURI.toString(),
|
||||||
|
HttpHeaders.cacheControlHeader: "no-store",
|
||||||
|
HttpHeaders.pragmaHeader: "no-cache"
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
111
packages/auth/lib/src/authorization_parser.dart
Normal file
111
packages/auth/lib/src/authorization_parser.dart
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
abstract class AuthorizationParser<T> {
|
||||||
|
const AuthorizationParser();
|
||||||
|
|
||||||
|
T parse(String authorizationHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a Bearer token from an Authorization header.
|
||||||
|
class AuthorizationBearerParser extends AuthorizationParser<String?> {
|
||||||
|
const AuthorizationBearerParser();
|
||||||
|
|
||||||
|
/// Parses a Bearer token from [authorizationHeader]. If the header is malformed or doesn't exist,
|
||||||
|
/// throws an [AuthorizationParserException]. Otherwise, returns the [String] representation of the bearer token.
|
||||||
|
///
|
||||||
|
/// For example, if the input to this method is "Bearer token" it would return 'token'.
|
||||||
|
///
|
||||||
|
/// If [authorizationHeader] is malformed or null, throws an [AuthorizationParserException].
|
||||||
|
@override
|
||||||
|
String? parse(String authorizationHeader) {
|
||||||
|
if (authorizationHeader.isEmpty) {
|
||||||
|
throw AuthorizationParserException(
|
||||||
|
AuthorizationParserExceptionReason.missing,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final matcher = RegExp("Bearer (.+)");
|
||||||
|
final match = matcher.firstMatch(authorizationHeader);
|
||||||
|
if (match == null) {
|
||||||
|
throw AuthorizationParserException(
|
||||||
|
AuthorizationParserExceptionReason.malformed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A structure to hold Basic authorization credentials.
|
||||||
|
///
|
||||||
|
/// See [AuthorizationBasicParser] for getting instances of this type.
|
||||||
|
class AuthBasicCredentials {
|
||||||
|
/// The username of a Basic Authorization header.
|
||||||
|
late final String username;
|
||||||
|
|
||||||
|
/// The password of a Basic Authorization header.
|
||||||
|
late final String password;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => "$username:$password";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a Basic Authorization header.
|
||||||
|
class AuthorizationBasicParser
|
||||||
|
extends AuthorizationParser<AuthBasicCredentials> {
|
||||||
|
const AuthorizationBasicParser();
|
||||||
|
|
||||||
|
/// Returns a [AuthBasicCredentials] containing the username and password
|
||||||
|
/// base64 encoded in [authorizationHeader]. For example, if the input to this method
|
||||||
|
/// was 'Basic base64String' it would decode the base64String
|
||||||
|
/// and return the username and password by splitting that decoded string around the character ':'.
|
||||||
|
///
|
||||||
|
/// If [authorizationHeader] is malformed or null, throws an [AuthorizationParserException].
|
||||||
|
@override
|
||||||
|
AuthBasicCredentials parse(String? authorizationHeader) {
|
||||||
|
if (authorizationHeader == null) {
|
||||||
|
throw AuthorizationParserException(
|
||||||
|
AuthorizationParserExceptionReason.missing,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final matcher = RegExp("Basic (.+)");
|
||||||
|
final match = matcher.firstMatch(authorizationHeader);
|
||||||
|
if (match == null) {
|
||||||
|
throw AuthorizationParserException(
|
||||||
|
AuthorizationParserExceptionReason.malformed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final base64String = match[1]!;
|
||||||
|
String decodedCredentials;
|
||||||
|
try {
|
||||||
|
decodedCredentials =
|
||||||
|
String.fromCharCodes(const Base64Decoder().convert(base64String));
|
||||||
|
} catch (e) {
|
||||||
|
throw AuthorizationParserException(
|
||||||
|
AuthorizationParserExceptionReason.malformed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final splitCredentials = decodedCredentials.split(":");
|
||||||
|
if (splitCredentials.length != 2) {
|
||||||
|
throw AuthorizationParserException(
|
||||||
|
AuthorizationParserExceptionReason.malformed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AuthBasicCredentials()
|
||||||
|
..username = splitCredentials.first
|
||||||
|
..password = splitCredentials.last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reason either [AuthorizationBearerParser] or [AuthorizationBasicParser] failed.
|
||||||
|
enum AuthorizationParserExceptionReason { missing, malformed }
|
||||||
|
|
||||||
|
/// An exception indicating why Authorization parsing failed.
|
||||||
|
class AuthorizationParserException implements Exception {
|
||||||
|
AuthorizationParserException(this.reason);
|
||||||
|
|
||||||
|
AuthorizationParserExceptionReason reason;
|
||||||
|
}
|
668
packages/auth/lib/src/authorization_server.dart
Normal file
668
packages/auth/lib/src/authorization_server.dart
Normal file
|
@ -0,0 +1,668 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:protevus_openapi/documentable.dart';
|
||||||
|
import 'package:protevus_auth/auth.dart';
|
||||||
|
import 'package:protevus_openapi/v3.dart';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
|
/// A OAuth 2.0 authorization server.
|
||||||
|
///
|
||||||
|
/// An [AuthServer] is an implementation of an OAuth 2.0 authorization server. An authorization server
|
||||||
|
/// issues, refreshes and revokes access tokens. It also verifies previously issued tokens, as
|
||||||
|
/// well as client and resource owner credentials.
|
||||||
|
///
|
||||||
|
/// [AuthServer]s are typically used in conjunction with [AuthController] and [AuthRedirectController].
|
||||||
|
/// These controllers provide HTTP interfaces to the [AuthServer] for issuing and refreshing tokens.
|
||||||
|
/// Likewise, [Authorizer]s verify these issued tokens to protect endpoint controllers.
|
||||||
|
///
|
||||||
|
/// [AuthServer]s can be customized through their [delegate]. This required property manages persistent storage of authorization
|
||||||
|
/// objects among other tasks. There are security considerations for [AuthServerDelegate] implementations; prefer to use a tested
|
||||||
|
/// implementation like `ManagedAuthDelegate` from `package:conduit_core/managed_auth.dart`.
|
||||||
|
///
|
||||||
|
/// Usage example with `ManagedAuthDelegate`:
|
||||||
|
///
|
||||||
|
/// import 'package:conduit_core/conduit_core.dart';
|
||||||
|
/// import 'package:conduit_core/managed_auth.dart';
|
||||||
|
///
|
||||||
|
/// class User extends ManagedObject<_User> implements _User, ManagedAuthResourceOwner {}
|
||||||
|
/// class _User extends ManagedAuthenticatable {}
|
||||||
|
///
|
||||||
|
/// class Channel extends ApplicationChannel {
|
||||||
|
/// ManagedContext context;
|
||||||
|
/// AuthServer authServer;
|
||||||
|
///
|
||||||
|
/// @override
|
||||||
|
/// Future prepare() async {
|
||||||
|
/// context = createContext();
|
||||||
|
///
|
||||||
|
/// final delegate = new ManagedAuthStorage<User>(context);
|
||||||
|
/// authServer = new AuthServer(delegate);
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// @override
|
||||||
|
/// Controller get entryPoint {
|
||||||
|
/// final router = new Router();
|
||||||
|
/// router
|
||||||
|
/// .route("/protected")
|
||||||
|
/// .link(() =>new Authorizer(authServer))
|
||||||
|
/// .link(() => new ProtectedResourceController());
|
||||||
|
///
|
||||||
|
/// router
|
||||||
|
/// .route("/auth/token")
|
||||||
|
/// .link(() => new AuthController(authServer));
|
||||||
|
///
|
||||||
|
/// return router;
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
class AuthServer implements AuthValidator, APIComponentDocumenter {
|
||||||
|
/// Creates a new instance of an [AuthServer] with a [delegate].
|
||||||
|
///
|
||||||
|
/// [hashFunction] defaults to [sha256].
|
||||||
|
AuthServer(
|
||||||
|
this.delegate, {
|
||||||
|
this.hashRounds = 1000,
|
||||||
|
this.hashLength = 32,
|
||||||
|
this.hashFunction = sha256,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The object responsible for carrying out the storage mechanisms of this instance.
|
||||||
|
///
|
||||||
|
/// This instance is responsible for storing, fetching and deleting instances of
|
||||||
|
/// [AuthToken], [AuthCode] and [AuthClient] by implementing the [AuthServerDelegate] interface.
|
||||||
|
///
|
||||||
|
/// It is preferable to use the implementation of [AuthServerDelegate] from 'package:conduit_core/managed_auth.dart'. See
|
||||||
|
/// [AuthServer] for more details.
|
||||||
|
final AuthServerDelegate delegate;
|
||||||
|
|
||||||
|
/// The number of hashing rounds performed by this instance when validating a password.
|
||||||
|
final int hashRounds;
|
||||||
|
|
||||||
|
/// The resulting key length of a password hash when generated by this instance.
|
||||||
|
final int hashLength;
|
||||||
|
|
||||||
|
/// The [Hash] function used by the PBKDF2 algorithm to generate password hashes by this instance.
|
||||||
|
final Hash hashFunction;
|
||||||
|
|
||||||
|
/// Used during OpenAPI documentation.
|
||||||
|
final APISecuritySchemeOAuth2Flow documentedAuthorizationCodeFlow =
|
||||||
|
APISecuritySchemeOAuth2Flow.empty()..scopes = {};
|
||||||
|
|
||||||
|
/// Used during OpenAPI documentation.
|
||||||
|
final APISecuritySchemeOAuth2Flow documentedPasswordFlow =
|
||||||
|
APISecuritySchemeOAuth2Flow.empty()..scopes = {};
|
||||||
|
|
||||||
|
/// Used during OpenAPI documentation.
|
||||||
|
final APISecuritySchemeOAuth2Flow documentedImplicitFlow =
|
||||||
|
APISecuritySchemeOAuth2Flow.empty()..scopes = {};
|
||||||
|
|
||||||
|
static const String tokenTypeBearer = "bearer";
|
||||||
|
|
||||||
|
/// Hashes a [password] with [salt] using PBKDF2 algorithm.
|
||||||
|
///
|
||||||
|
/// See [hashRounds], [hashLength] and [hashFunction] for more details. This method
|
||||||
|
/// invoke [auth.generatePasswordHash] with the above inputs.
|
||||||
|
String hashPassword(String password, String salt) {
|
||||||
|
return generatePasswordHash(
|
||||||
|
password,
|
||||||
|
salt,
|
||||||
|
hashRounds: hashRounds,
|
||||||
|
hashLength: hashLength,
|
||||||
|
hashFunction: hashFunction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds an OAuth2 client.
|
||||||
|
///
|
||||||
|
/// [delegate] will store this client for future use.
|
||||||
|
Future addClient(AuthClient client) async {
|
||||||
|
if (client.id.isEmpty) {
|
||||||
|
throw ArgumentError(
|
||||||
|
"A client must have an id.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.redirectURI != null && client.hashedSecret == null) {
|
||||||
|
throw ArgumentError(
|
||||||
|
"A client with a redirectURI must have a client secret.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return delegate.addClient(this, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a [AuthClient] record for its [clientID].
|
||||||
|
///
|
||||||
|
/// Returns null if none exists.
|
||||||
|
Future<AuthClient?> getClient(String clientID) async {
|
||||||
|
return delegate.getClient(this, clientID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revokes a [AuthClient] record.
|
||||||
|
///
|
||||||
|
/// Removes cached occurrences of [AuthClient] for [clientID].
|
||||||
|
/// Asks [delegate] to remove an [AuthClient] by its ID via [AuthServerDelegate.removeClient].
|
||||||
|
Future removeClient(String clientID) async {
|
||||||
|
if (clientID.isEmpty) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return delegate.removeClient(this, clientID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revokes access for an [ResourceOwner].
|
||||||
|
///
|
||||||
|
/// All authorization codes and tokens for the [ResourceOwner] identified by [identifier]
|
||||||
|
/// will be revoked.
|
||||||
|
Future revokeAllGrantsForResourceOwner(int? identifier) async {
|
||||||
|
if (identifier == null) {
|
||||||
|
throw ArgumentError.notNull("identifier");
|
||||||
|
}
|
||||||
|
|
||||||
|
await delegate.removeTokens(this, identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticates a username and password of an [ResourceOwner] and returns an [AuthToken] upon success.
|
||||||
|
///
|
||||||
|
/// This method works with this instance's [delegate] to generate and store a new token if all credentials are correct.
|
||||||
|
/// If credentials are not correct, it will throw the appropriate [AuthRequestError].
|
||||||
|
///
|
||||||
|
/// After [expiration], this token will no longer be valid.
|
||||||
|
Future<AuthToken> authenticate(
|
||||||
|
String? username,
|
||||||
|
String? password,
|
||||||
|
String clientID,
|
||||||
|
String? clientSecret, {
|
||||||
|
Duration expiration = const Duration(hours: 24),
|
||||||
|
List<AuthScope>? requestedScopes,
|
||||||
|
}) async {
|
||||||
|
if (clientID.isEmpty) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = await getClient(clientID);
|
||||||
|
if (client == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username == null || password == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidRequest, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.isPublic) {
|
||||||
|
if (!(clientSecret == null || clientSecret == "")) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (clientSecret == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.hashedSecret != hashPassword(clientSecret, client.salt!)) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final authenticatable = await delegate.getResourceOwner(this, username);
|
||||||
|
if (authenticatable == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidGrant, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
final dbSalt = authenticatable.salt!;
|
||||||
|
final dbPassword = authenticatable.hashedPassword;
|
||||||
|
final hash = hashPassword(password, dbSalt);
|
||||||
|
if (hash != dbPassword) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidGrant, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
final validScopes =
|
||||||
|
_validatedScopes(client, authenticatable, requestedScopes);
|
||||||
|
final token = _generateToken(
|
||||||
|
authenticatable.id,
|
||||||
|
client.id,
|
||||||
|
expiration.inSeconds,
|
||||||
|
allowRefresh: !client.isPublic,
|
||||||
|
scopes: validScopes,
|
||||||
|
);
|
||||||
|
await delegate.addToken(this, token);
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a [Authorization] for [accessToken].
|
||||||
|
///
|
||||||
|
/// This method obtains an [AuthToken] for [accessToken] from [delegate] and then verifies that the token is valid.
|
||||||
|
/// If the token is valid, an [Authorization] object is returned. Otherwise, an [AuthServerException] is thrown.
|
||||||
|
Future<Authorization> verify(
|
||||||
|
String? accessToken, {
|
||||||
|
List<AuthScope>? scopesRequired,
|
||||||
|
}) async {
|
||||||
|
if (accessToken == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidRequest, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
final t = await delegate.getToken(this, byAccessToken: accessToken);
|
||||||
|
if (t == null || t.isExpired) {
|
||||||
|
throw AuthServerException(
|
||||||
|
AuthRequestError.invalidGrant,
|
||||||
|
AuthClient(t?.clientID ?? '', null, null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopesRequired != null) {
|
||||||
|
if (!AuthScope.verify(scopesRequired, t.scopes)) {
|
||||||
|
throw AuthServerException(
|
||||||
|
AuthRequestError.invalidScope,
|
||||||
|
AuthClient(t.clientID, null, null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Authorization(
|
||||||
|
t.clientID,
|
||||||
|
t.resourceOwnerIdentifier,
|
||||||
|
this,
|
||||||
|
scopes: t.scopes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes a valid [AuthToken] instance.
|
||||||
|
///
|
||||||
|
/// This method will refresh a [AuthToken] given the [AuthToken]'s [refreshToken] for a given client ID.
|
||||||
|
/// This method coordinates with this instance's [delegate] to update the old token with a new access token and issue/expiration dates if successful.
|
||||||
|
/// If not successful, it will throw an [AuthRequestError].
|
||||||
|
Future<AuthToken> refresh(
|
||||||
|
String? refreshToken,
|
||||||
|
String clientID,
|
||||||
|
String? clientSecret, {
|
||||||
|
List<AuthScope>? requestedScopes,
|
||||||
|
}) async {
|
||||||
|
if (clientID.isEmpty) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = await getClient(clientID);
|
||||||
|
if (client == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshToken == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidRequest, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
final t = await delegate.getToken(this, byRefreshToken: refreshToken);
|
||||||
|
if (t == null || t.clientID != clientID) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidGrant, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientSecret == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.hashedSecret != hashPassword(clientSecret, client.salt!)) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedScopes = t.scopes;
|
||||||
|
if ((requestedScopes?.length ?? 0) != 0) {
|
||||||
|
// If we do specify scope
|
||||||
|
for (final incomingScope in requestedScopes!) {
|
||||||
|
final hasExistingScopeOrSuperset = t.scopes!.any(
|
||||||
|
(existingScope) => incomingScope.isSubsetOrEqualTo(existingScope),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasExistingScopeOrSuperset) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidScope, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.allowsScope(incomingScope)) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidScope, client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedScopes = requestedScopes;
|
||||||
|
} else if (client.supportsScopes) {
|
||||||
|
// Ensure we still have access to same scopes if we didn't specify any
|
||||||
|
for (final incomingScope in t.scopes!) {
|
||||||
|
if (!client.allowsScope(incomingScope)) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidScope, client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final diff = t.expirationDate!.difference(t.issueDate!);
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
final newToken = AuthToken()
|
||||||
|
..accessToken = randomStringOfLength(32)
|
||||||
|
..issueDate = now
|
||||||
|
..expirationDate = now.add(Duration(seconds: diff.inSeconds)).toUtc()
|
||||||
|
..refreshToken = t.refreshToken
|
||||||
|
..type = t.type
|
||||||
|
..scopes = updatedScopes
|
||||||
|
..resourceOwnerIdentifier = t.resourceOwnerIdentifier
|
||||||
|
..clientID = t.clientID;
|
||||||
|
|
||||||
|
await delegate.updateToken(
|
||||||
|
this,
|
||||||
|
t.accessToken,
|
||||||
|
newToken.accessToken,
|
||||||
|
newToken.issueDate,
|
||||||
|
newToken.expirationDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
return newToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a one-time use authorization code for a given client ID and user credentials.
|
||||||
|
///
|
||||||
|
/// This methods works with this instance's [delegate] to generate and store the authorization code
|
||||||
|
/// if the credentials are correct. If they are not correct, it will throw the
|
||||||
|
/// appropriate [AuthRequestError].
|
||||||
|
Future<AuthCode> authenticateForCode(
|
||||||
|
String? username,
|
||||||
|
String? password,
|
||||||
|
String clientID, {
|
||||||
|
int expirationInSeconds = 600,
|
||||||
|
List<AuthScope>? requestedScopes,
|
||||||
|
}) async {
|
||||||
|
if (clientID.isEmpty) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = await getClient(clientID);
|
||||||
|
if (client == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username == null || password == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidRequest, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.redirectURI == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.unauthorizedClient, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
final authenticatable = await delegate.getResourceOwner(this, username);
|
||||||
|
if (authenticatable == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.accessDenied, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
final dbSalt = authenticatable.salt;
|
||||||
|
final dbPassword = authenticatable.hashedPassword;
|
||||||
|
if (hashPassword(password, dbSalt!) != dbPassword) {
|
||||||
|
throw AuthServerException(AuthRequestError.accessDenied, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
final validScopes =
|
||||||
|
_validatedScopes(client, authenticatable, requestedScopes);
|
||||||
|
final authCode = _generateAuthCode(
|
||||||
|
authenticatable.id,
|
||||||
|
client,
|
||||||
|
expirationInSeconds,
|
||||||
|
scopes: validScopes,
|
||||||
|
);
|
||||||
|
await delegate.addCode(this, authCode);
|
||||||
|
return authCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exchanges a valid authorization code for an [AuthToken].
|
||||||
|
///
|
||||||
|
/// If the authorization code has not expired, has not been used, matches the client ID,
|
||||||
|
/// and the client secret is correct, it will return a valid [AuthToken]. Otherwise,
|
||||||
|
/// it will throw an appropriate [AuthRequestError].
|
||||||
|
Future<AuthToken> exchange(
|
||||||
|
String? authCodeString,
|
||||||
|
String clientID,
|
||||||
|
String? clientSecret, {
|
||||||
|
int expirationInSeconds = 3600,
|
||||||
|
}) async {
|
||||||
|
if (clientID.isEmpty) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = await getClient(clientID);
|
||||||
|
if (client == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authCodeString == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidRequest, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientSecret == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.hashedSecret != hashPassword(clientSecret, client.salt!)) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
final authCode = await delegate.getCode(this, authCodeString);
|
||||||
|
if (authCode == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidGrant, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if valid still
|
||||||
|
if (authCode.isExpired) {
|
||||||
|
await delegate.removeCode(this, authCode.code);
|
||||||
|
throw AuthServerException(AuthRequestError.invalidGrant, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check that client ids match
|
||||||
|
if (authCode.clientID != client.id) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidGrant, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check to see if has already been used
|
||||||
|
if (authCode.hasBeenExchanged!) {
|
||||||
|
await delegate.removeToken(this, authCode);
|
||||||
|
|
||||||
|
throw AuthServerException(AuthRequestError.invalidGrant, client);
|
||||||
|
}
|
||||||
|
final token = _generateToken(
|
||||||
|
authCode.resourceOwnerIdentifier,
|
||||||
|
client.id,
|
||||||
|
expirationInSeconds,
|
||||||
|
scopes: authCode.requestedScopes,
|
||||||
|
);
|
||||||
|
await delegate.addToken(this, token, issuedFrom: authCode);
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
//////
|
||||||
|
// APIDocumentable overrides
|
||||||
|
//////
|
||||||
|
@override
|
||||||
|
void documentComponents(APIDocumentContext context) {
|
||||||
|
final basic = APISecurityScheme.http("basic")
|
||||||
|
..description =
|
||||||
|
"This endpoint requires an OAuth2 Client ID and Secret as the Basic Authentication username and password. "
|
||||||
|
"If the client ID does not have a secret (public client), the password is the empty string (retain the separating colon, e.g. 'com.conduit.app:').";
|
||||||
|
context.securitySchemes.register("oauth2-client-authentication", basic);
|
||||||
|
|
||||||
|
final oauth2 = APISecurityScheme.oauth2({
|
||||||
|
"authorizationCode": documentedAuthorizationCodeFlow,
|
||||||
|
"password": documentedPasswordFlow
|
||||||
|
})
|
||||||
|
..description = "Standard OAuth 2.0";
|
||||||
|
|
||||||
|
context.securitySchemes.register("oauth2", oauth2);
|
||||||
|
|
||||||
|
context.defer(() {
|
||||||
|
if (documentedAuthorizationCodeFlow.authorizationURL == null) {
|
||||||
|
oauth2.flows!.remove("authorizationCode");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (documentedAuthorizationCodeFlow.tokenURL == null) {
|
||||||
|
oauth2.flows!.remove("authorizationCode");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (documentedPasswordFlow.tokenURL == null) {
|
||||||
|
oauth2.flows!.remove("password");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/////
|
||||||
|
// AuthValidator overrides
|
||||||
|
/////
|
||||||
|
@override
|
||||||
|
List<APISecurityRequirement> documentRequirementsForAuthorizer(
|
||||||
|
APIDocumentContext context,
|
||||||
|
Authorizer authorizer, {
|
||||||
|
List<AuthScope>? scopes,
|
||||||
|
}) {
|
||||||
|
if (authorizer.parser is AuthorizationBasicParser) {
|
||||||
|
return [
|
||||||
|
APISecurityRequirement({"oauth2-client-authentication": []})
|
||||||
|
];
|
||||||
|
} else if (authorizer.parser is AuthorizationBearerParser) {
|
||||||
|
return [
|
||||||
|
APISecurityRequirement(
|
||||||
|
{"oauth2": scopes?.map((s) => s.toString()).toList() ?? []},
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<Authorization> validate<T>(
|
||||||
|
AuthorizationParser<T> parser,
|
||||||
|
T authorizationData, {
|
||||||
|
List<AuthScope>? requiredScope,
|
||||||
|
}) {
|
||||||
|
if (parser is AuthorizationBasicParser) {
|
||||||
|
final credentials = authorizationData as AuthBasicCredentials;
|
||||||
|
return _validateClientCredentials(credentials);
|
||||||
|
} else if (parser is AuthorizationBearerParser) {
|
||||||
|
return verify(authorizationData as String, scopesRequired: requiredScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ArgumentError(
|
||||||
|
"Invalid 'parser' for 'AuthServer.validate'. Use 'AuthorizationBasicParser' or 'AuthorizationBearerHeader'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Authorization> _validateClientCredentials(
|
||||||
|
AuthBasicCredentials credentials,
|
||||||
|
) async {
|
||||||
|
final username = credentials.username;
|
||||||
|
final password = credentials.password;
|
||||||
|
|
||||||
|
final client = await getClient(username);
|
||||||
|
|
||||||
|
if (client == null) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.hashedSecret == null) {
|
||||||
|
if (password == "") {
|
||||||
|
return Authorization(client.id, null, this, credentials: credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.hashedSecret != hashPassword(password, client.salt!)) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Authorization(client.id, null, this, credentials: credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AuthScope>? _validatedScopes(
|
||||||
|
AuthClient client,
|
||||||
|
ResourceOwner authenticatable,
|
||||||
|
List<AuthScope>? requestedScopes,
|
||||||
|
) {
|
||||||
|
List<AuthScope>? validScopes;
|
||||||
|
if (client.supportsScopes) {
|
||||||
|
if ((requestedScopes?.length ?? 0) == 0) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidScope, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
validScopes = requestedScopes!
|
||||||
|
.where((incomingScope) => client.allowsScope(incomingScope))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (validScopes.isEmpty) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidScope, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
final validScopesForAuthenticatable =
|
||||||
|
delegate.getAllowedScopes(authenticatable);
|
||||||
|
if (!identical(validScopesForAuthenticatable, AuthScope.any)) {
|
||||||
|
validScopes.retainWhere(
|
||||||
|
(clientAllowedScope) => validScopesForAuthenticatable!.any(
|
||||||
|
(userScope) => clientAllowedScope.isSubsetOrEqualTo(userScope),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validScopes.isEmpty) {
|
||||||
|
throw AuthServerException(AuthRequestError.invalidScope, client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return validScopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthToken _generateToken(
|
||||||
|
int? ownerID,
|
||||||
|
String clientID,
|
||||||
|
int expirationInSeconds, {
|
||||||
|
bool allowRefresh = true,
|
||||||
|
List<AuthScope>? scopes,
|
||||||
|
}) {
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
final token = AuthToken()
|
||||||
|
..accessToken = randomStringOfLength(32)
|
||||||
|
..issueDate = now
|
||||||
|
..expirationDate = now.add(Duration(seconds: expirationInSeconds))
|
||||||
|
..type = tokenTypeBearer
|
||||||
|
..resourceOwnerIdentifier = ownerID
|
||||||
|
..scopes = scopes
|
||||||
|
..clientID = clientID;
|
||||||
|
|
||||||
|
if (allowRefresh) {
|
||||||
|
token.refreshToken = randomStringOfLength(32);
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthCode _generateAuthCode(
|
||||||
|
int? ownerID,
|
||||||
|
AuthClient client,
|
||||||
|
int expirationInSeconds, {
|
||||||
|
List<AuthScope>? scopes,
|
||||||
|
}) {
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
return AuthCode()
|
||||||
|
..code = randomStringOfLength(32)
|
||||||
|
..clientID = client.id
|
||||||
|
..resourceOwnerIdentifier = ownerID
|
||||||
|
..issueDate = now
|
||||||
|
..requestedScopes = scopes
|
||||||
|
..expirationDate = now.add(Duration(seconds: expirationInSeconds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String randomStringOfLength(int length) {
|
||||||
|
const possibleCharacters =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
final buff = StringBuffer();
|
||||||
|
|
||||||
|
final r = Random.secure();
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
buff.write(
|
||||||
|
possibleCharacters[r.nextInt(1000) % possibleCharacters.length],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buff.toString();
|
||||||
|
}
|
228
packages/auth/lib/src/authorizer.dart
Normal file
228
packages/auth/lib/src/authorizer.dart
Normal file
|
@ -0,0 +1,228 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:protevus_openapi/documentable.dart';
|
||||||
|
import 'package:protevus_auth/auth.dart';
|
||||||
|
import 'package:protevus_http/http.dart';
|
||||||
|
import 'package:protevus_openapi/v3.dart';
|
||||||
|
|
||||||
|
/// A [Controller] that validates the Authorization header of a request.
|
||||||
|
///
|
||||||
|
/// An instance of this type will validate that the authorization information in an Authorization header is sufficient to access
|
||||||
|
/// the next controller in the channel.
|
||||||
|
///
|
||||||
|
/// For each request, this controller parses the authorization header, validates it with an [AuthValidator] and then create an [Authorization] object
|
||||||
|
/// if successful. The [Request] keeps a reference to this [Authorization] and is then sent to the next controller in the channel.
|
||||||
|
///
|
||||||
|
/// If either parsing or validation fails, a 401 Unauthorized response is sent and the [Request] is removed from the channel.
|
||||||
|
///
|
||||||
|
/// Parsing occurs according to [parser]. The resulting value (e.g., username and password) is sent to [validator].
|
||||||
|
/// [validator] verifies this value (e.g., lookup a user in the database and verify their password matches).
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
///
|
||||||
|
/// router
|
||||||
|
/// .route("/protected-route")
|
||||||
|
/// .link(() =>new Authorizer.bearer(authServer))
|
||||||
|
/// .link(() => new ProtectedResourceController());
|
||||||
|
class Authorizer extends Controller {
|
||||||
|
/// Creates an instance of [Authorizer].
|
||||||
|
///
|
||||||
|
/// Use this constructor to provide custom [AuthorizationParser]s.
|
||||||
|
///
|
||||||
|
/// By default, this instance will parse bearer tokens from the authorization header, e.g.:
|
||||||
|
///
|
||||||
|
/// Authorization: Bearer ap9ijlarlkz8jIOa9laweo
|
||||||
|
///
|
||||||
|
/// If [scopes] is provided, the authorization granted must have access to *all* scopes according to [validator].
|
||||||
|
Authorizer(
|
||||||
|
this.validator, {
|
||||||
|
this.parser = const AuthorizationBearerParser(),
|
||||||
|
List<String>? scopes,
|
||||||
|
}) : scopes = scopes?.map((s) => AuthScope(s)).toList();
|
||||||
|
|
||||||
|
/// Creates an instance of [Authorizer] with Basic Authentication parsing.
|
||||||
|
///
|
||||||
|
/// Parses a username and password from the request's Basic Authentication data in the Authorization header, e.g.:
|
||||||
|
///
|
||||||
|
/// Authorization: Basic base64(username:password)
|
||||||
|
Authorizer.basic(AuthValidator? validator)
|
||||||
|
: this(validator, parser: const AuthorizationBasicParser());
|
||||||
|
|
||||||
|
/// Creates an instance of [Authorizer] with Bearer token parsing.
|
||||||
|
///
|
||||||
|
/// Parses a bearer token from the request's Authorization header, e.g.
|
||||||
|
///
|
||||||
|
/// Authorization: Bearer ap9ijlarlkz8jIOa9laweo
|
||||||
|
///
|
||||||
|
/// If [scopes] is provided, the bearer token must have access to *all* scopes according to [validator].
|
||||||
|
Authorizer.bearer(AuthValidator? validator, {List<String>? scopes})
|
||||||
|
: this(
|
||||||
|
validator,
|
||||||
|
parser: const AuthorizationBearerParser(),
|
||||||
|
scopes: scopes,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The validating authorization object.
|
||||||
|
///
|
||||||
|
/// This object will check credentials parsed from the Authorization header and produce an
|
||||||
|
/// [Authorization] object representing the authorization the credentials have. It may also
|
||||||
|
/// reject a request. This is typically an instance of [AuthServer].
|
||||||
|
final AuthValidator? validator;
|
||||||
|
|
||||||
|
/// The list of required scopes.
|
||||||
|
///
|
||||||
|
/// If [validator] grants scope-limited authorizations (e.g., OAuth2 bearer tokens), the authorization
|
||||||
|
/// provided by the request's header must have access to all [scopes] in order to move on to the next controller.
|
||||||
|
///
|
||||||
|
/// This property is set with a list of scope strings in a constructor. Each scope string is parsed into
|
||||||
|
/// an [AuthScope] and added to this list.
|
||||||
|
final List<AuthScope>? scopes;
|
||||||
|
|
||||||
|
/// Parses the Authorization header.
|
||||||
|
///
|
||||||
|
/// The parser determines how to interpret the data in the Authorization header. Concrete subclasses
|
||||||
|
/// are [AuthorizationBasicParser] and [AuthorizationBearerParser].
|
||||||
|
///
|
||||||
|
/// Once parsed, the parsed value is validated by [validator].
|
||||||
|
final AuthorizationParser parser;
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<RequestOrResponse> handle(Request request) async {
|
||||||
|
final authData = request.raw.headers.value(HttpHeaders.authorizationHeader);
|
||||||
|
if (authData == null) {
|
||||||
|
return Response.unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final value = parser.parse(authData);
|
||||||
|
request.authorization =
|
||||||
|
await validator!.validate(parser, value, requiredScope: scopes);
|
||||||
|
if (request.authorization == null) {
|
||||||
|
return Response.unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
_addScopeRequirementModifier(request);
|
||||||
|
} on AuthorizationParserException catch (e) {
|
||||||
|
return _responseFromParseException(e);
|
||||||
|
} on AuthServerException catch (e) {
|
||||||
|
if (e.reason == AuthRequestError.invalidScope) {
|
||||||
|
return Response.forbidden(
|
||||||
|
body: {
|
||||||
|
"error": "insufficient_scope",
|
||||||
|
"scope": scopes!.map((s) => s.toString()).join(" ")
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response _responseFromParseException(AuthorizationParserException e) {
|
||||||
|
switch (e.reason) {
|
||||||
|
case AuthorizationParserExceptionReason.malformed:
|
||||||
|
return Response.badRequest(
|
||||||
|
body: {"error": "invalid_authorization_header"},
|
||||||
|
);
|
||||||
|
case AuthorizationParserExceptionReason.missing:
|
||||||
|
return Response.unauthorized();
|
||||||
|
default:
|
||||||
|
return Response.serverError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addScopeRequirementModifier(Request request) {
|
||||||
|
// If a controller returns a 403 because of invalid scope,
|
||||||
|
// this Authorizer adds its required scope as well.
|
||||||
|
if (scopes != null) {
|
||||||
|
request.addResponseModifier((resp) {
|
||||||
|
if (resp.statusCode == 403 && resp.body is Map) {
|
||||||
|
final body = resp.body as Map<String, dynamic>;
|
||||||
|
if (body.containsKey("scope")) {
|
||||||
|
final declaredScopes = (body["scope"] as String).split(" ");
|
||||||
|
final scopesToAdd = scopes!
|
||||||
|
.map((s) => s.toString())
|
||||||
|
.where((s) => !declaredScopes.contains(s));
|
||||||
|
body["scope"] =
|
||||||
|
[scopesToAdd, declaredScopes].expand((i) => i).join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void documentComponents(APIDocumentContext context) {
|
||||||
|
super.documentComponents(context);
|
||||||
|
|
||||||
|
context.responses.register(
|
||||||
|
"InsufficientScope",
|
||||||
|
APIResponse(
|
||||||
|
"The provided credentials or bearer token have insufficient permission to access this route.",
|
||||||
|
content: {
|
||||||
|
"application/json": APIMediaType(
|
||||||
|
schema: APISchemaObject.object({
|
||||||
|
"error": APISchemaObject.string(),
|
||||||
|
"scope": APISchemaObject.string()
|
||||||
|
..description = "The required scope for this operation."
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
context.responses.register(
|
||||||
|
"InsufficientAccess",
|
||||||
|
APIResponse(
|
||||||
|
"The provided credentials or bearer token are not authorized for this request.",
|
||||||
|
content: {
|
||||||
|
"application/json": APIMediaType(
|
||||||
|
schema: APISchemaObject.object(
|
||||||
|
{"error": APISchemaObject.string()},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
context.responses.register(
|
||||||
|
"MalformedAuthorizationHeader",
|
||||||
|
APIResponse(
|
||||||
|
"The provided Authorization header was malformed.",
|
||||||
|
content: {
|
||||||
|
"application/json": APIMediaType(
|
||||||
|
schema: APISchemaObject.object(
|
||||||
|
{"error": APISchemaObject.string()},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, APIOperation> documentOperations(
|
||||||
|
APIDocumentContext context,
|
||||||
|
String route,
|
||||||
|
APIPath path,
|
||||||
|
) {
|
||||||
|
final operations = super.documentOperations(context, route, path);
|
||||||
|
|
||||||
|
operations.forEach((_, op) {
|
||||||
|
op.addResponse(400, context.responses["MalformedAuthorizationHeader"]);
|
||||||
|
op.addResponse(401, context.responses["InsufficientAccess"]);
|
||||||
|
op.addResponse(403, context.responses["InsufficientScope"]);
|
||||||
|
|
||||||
|
final requirements = validator!
|
||||||
|
.documentRequirementsForAuthorizer(context, this, scopes: scopes);
|
||||||
|
for (final req in requirements) {
|
||||||
|
op.addSecurityRequirement(req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return operations;
|
||||||
|
}
|
||||||
|
}
|
121
packages/auth/lib/src/exceptions.dart
Normal file
121
packages/auth/lib/src/exceptions.dart
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
import 'package:protevus_auth/auth.dart';
|
||||||
|
|
||||||
|
/// An exception thrown by [AuthServer].
|
||||||
|
class AuthServerException implements Exception {
|
||||||
|
AuthServerException(this.reason, this.client);
|
||||||
|
|
||||||
|
/// Returns a string suitable to be included in a query string or JSON response body
|
||||||
|
/// to indicate the error during processing an OAuth 2.0 request.
|
||||||
|
static String errorString(AuthRequestError error) {
|
||||||
|
switch (error) {
|
||||||
|
case AuthRequestError.invalidRequest:
|
||||||
|
return "invalid_request";
|
||||||
|
case AuthRequestError.invalidClient:
|
||||||
|
return "invalid_client";
|
||||||
|
case AuthRequestError.invalidGrant:
|
||||||
|
return "invalid_grant";
|
||||||
|
case AuthRequestError.invalidScope:
|
||||||
|
return "invalid_scope";
|
||||||
|
case AuthRequestError.invalidToken:
|
||||||
|
return "invalid_token";
|
||||||
|
|
||||||
|
case AuthRequestError.unsupportedGrantType:
|
||||||
|
return "unsupported_grant_type";
|
||||||
|
case AuthRequestError.unsupportedResponseType:
|
||||||
|
return "unsupported_response_type";
|
||||||
|
|
||||||
|
case AuthRequestError.unauthorizedClient:
|
||||||
|
return "unauthorized_client";
|
||||||
|
case AuthRequestError.accessDenied:
|
||||||
|
return "access_denied";
|
||||||
|
|
||||||
|
case AuthRequestError.serverError:
|
||||||
|
return "server_error";
|
||||||
|
case AuthRequestError.temporarilyUnavailable:
|
||||||
|
return "temporarily_unavailable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthRequestError reason;
|
||||||
|
AuthClient? client;
|
||||||
|
|
||||||
|
String get reasonString {
|
||||||
|
return errorString(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return "AuthServerException: $reason $client";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The possible errors as defined by the OAuth 2.0 specification.
|
||||||
|
///
|
||||||
|
/// Auth endpoints will use this list of values to determine the response sent back
|
||||||
|
/// to a client upon a failed request.
|
||||||
|
enum AuthRequestError {
|
||||||
|
/// The request was invalid...
|
||||||
|
///
|
||||||
|
/// The request is missing a required parameter, includes an
|
||||||
|
/// unsupported parameter value (other than grant type),
|
||||||
|
/// repeats a parameter, includes multiple credentials,
|
||||||
|
/// utilizes more than one mechanism for authenticating the
|
||||||
|
/// client, or is otherwise malformed.
|
||||||
|
invalidRequest,
|
||||||
|
|
||||||
|
/// The client was invalid...
|
||||||
|
///
|
||||||
|
/// Client authentication failed (e.g., unknown client, no
|
||||||
|
/// client authentication included, or unsupported
|
||||||
|
/// authentication method). The authorization server MAY
|
||||||
|
/// return an HTTP 401 (Unauthorized) status code to indicate
|
||||||
|
/// which HTTP authentication schemes are supported. If the
|
||||||
|
/// client attempted to authenticate via the "Authorization"
|
||||||
|
/// request header field, the authorization server MUST
|
||||||
|
/// respond with an HTTP 401 (Unauthorized) status code and
|
||||||
|
/// include the "WWW-Authenticate" response header field
|
||||||
|
/// matching the authentication scheme used by the client.
|
||||||
|
invalidClient,
|
||||||
|
|
||||||
|
/// The grant was invalid...
|
||||||
|
///
|
||||||
|
/// The provided authorization grant (e.g., authorization
|
||||||
|
/// code, resource owner credentials) or refresh token is
|
||||||
|
/// invalid, expired, revoked, does not match the redirection
|
||||||
|
/// URI used in the authorization request, or was issued to
|
||||||
|
/// another client.
|
||||||
|
invalidGrant,
|
||||||
|
|
||||||
|
/// The requested scope is invalid, unknown, malformed, or exceeds the scope granted by the resource owner.
|
||||||
|
///
|
||||||
|
invalidScope,
|
||||||
|
|
||||||
|
/// The authorization grant type is not supported by the authorization server.
|
||||||
|
///
|
||||||
|
unsupportedGrantType,
|
||||||
|
|
||||||
|
/// The authorization server does not support obtaining an authorization code using this method.
|
||||||
|
///
|
||||||
|
unsupportedResponseType,
|
||||||
|
|
||||||
|
/// The authenticated client is not authorized to use this authorization grant type.
|
||||||
|
///
|
||||||
|
unauthorizedClient,
|
||||||
|
|
||||||
|
/// The resource owner or authorization server denied the request.
|
||||||
|
///
|
||||||
|
accessDenied,
|
||||||
|
|
||||||
|
/// The server encountered an error during processing the request.
|
||||||
|
///
|
||||||
|
serverError,
|
||||||
|
|
||||||
|
/// The server is temporarily unable to fulfill the request.
|
||||||
|
///
|
||||||
|
temporarilyUnavailable,
|
||||||
|
|
||||||
|
/// Indicates that the token is invalid.
|
||||||
|
///
|
||||||
|
/// This particular error reason is not part of the OAuth 2.0 spec.
|
||||||
|
invalidToken
|
||||||
|
}
|
541
packages/auth/lib/src/objects.dart
Normal file
541
packages/auth/lib/src/objects.dart
Normal file
|
@ -0,0 +1,541 @@
|
||||||
|
import 'package:protevus_auth/auth.dart';
|
||||||
|
import 'package:protevus_http/http.dart';
|
||||||
|
|
||||||
|
/// Represents an OAuth 2.0 client ID and secret pair.
|
||||||
|
///
|
||||||
|
/// See the conduit/managed_auth library for a concrete implementation of this type.
|
||||||
|
///
|
||||||
|
/// Use the command line tool `conduit auth` to create instances of this type and store them to a database.
|
||||||
|
class AuthClient {
|
||||||
|
/// Creates an instance of [AuthClient].
|
||||||
|
///
|
||||||
|
/// [id] must not be null. [hashedSecret] and [salt] must either both be null or both be valid values. If [hashedSecret] and [salt]
|
||||||
|
/// are valid values, this client is a confidential client. Otherwise, the client is public. The terms 'confidential' and 'public'
|
||||||
|
/// are described by the OAuth 2.0 specification.
|
||||||
|
///
|
||||||
|
/// If this client supports scopes, [allowedScopes] must contain a list of scopes that tokens may request when authorized
|
||||||
|
/// by this client.
|
||||||
|
AuthClient(
|
||||||
|
String id,
|
||||||
|
String? hashedSecret,
|
||||||
|
String? salt, {
|
||||||
|
List<AuthScope>? allowedScopes,
|
||||||
|
}) : this.withRedirectURI(
|
||||||
|
id,
|
||||||
|
hashedSecret,
|
||||||
|
salt,
|
||||||
|
null,
|
||||||
|
allowedScopes: allowedScopes,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates an instance of a public [AuthClient].
|
||||||
|
AuthClient.public(String id,
|
||||||
|
{List<AuthScope>? allowedScopes, String? redirectURI})
|
||||||
|
: this.withRedirectURI(
|
||||||
|
id,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
redirectURI,
|
||||||
|
allowedScopes: allowedScopes,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates an instance of [AuthClient] that uses the authorization code grant flow.
|
||||||
|
///
|
||||||
|
/// All values must be non-null. This is confidential client.
|
||||||
|
AuthClient.withRedirectURI(
|
||||||
|
this.id,
|
||||||
|
this.hashedSecret,
|
||||||
|
this.salt,
|
||||||
|
this.redirectURI, {
|
||||||
|
List<AuthScope>? allowedScopes,
|
||||||
|
}) {
|
||||||
|
this.allowedScopes = allowedScopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AuthScope>? _allowedScopes;
|
||||||
|
|
||||||
|
/// The ID of the client.
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// The hashed secret of the client.
|
||||||
|
///
|
||||||
|
/// This value may be null if the client is public. See [isPublic].
|
||||||
|
String? hashedSecret;
|
||||||
|
|
||||||
|
/// The salt [hashedSecret] was hashed with.
|
||||||
|
///
|
||||||
|
/// This value may be null if the client is public. See [isPublic].
|
||||||
|
String? salt;
|
||||||
|
|
||||||
|
/// The redirection URI for authorization codes and/or tokens.
|
||||||
|
///
|
||||||
|
/// This value may be null if the client doesn't support the authorization code flow.
|
||||||
|
String? redirectURI;
|
||||||
|
|
||||||
|
/// The list of scopes available when authorizing with this client.
|
||||||
|
///
|
||||||
|
/// Scoping is determined by this instance; i.e. the authorizing client determines which scopes a token
|
||||||
|
/// has. This list contains all valid scopes for this client. If null, client does not support scopes
|
||||||
|
/// and all access tokens have same authorization.
|
||||||
|
List<AuthScope>? get allowedScopes => _allowedScopes;
|
||||||
|
set allowedScopes(List<AuthScope>? scopes) {
|
||||||
|
_allowedScopes = scopes?.where((s) {
|
||||||
|
return !scopes.any(
|
||||||
|
(otherScope) =>
|
||||||
|
s.isSubsetOrEqualTo(otherScope) && !s.isExactlyScope(otherScope),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether or not this instance allows scoping or not.
|
||||||
|
///
|
||||||
|
/// In application's that do not use authorization scopes, this will return false.
|
||||||
|
/// Otherwise, will return true.
|
||||||
|
bool get supportsScopes => allowedScopes != null;
|
||||||
|
|
||||||
|
/// Whether or not this client can issue tokens for the provided [scope].
|
||||||
|
bool allowsScope(AuthScope scope) {
|
||||||
|
return allowedScopes
|
||||||
|
?.any((clientScope) => scope.isSubsetOrEqualTo(clientScope)) ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether or not this is a public or confidential client.
|
||||||
|
///
|
||||||
|
/// Public clients do not have a client secret and are used for clients that can't store
|
||||||
|
/// their secret confidentially, i.e. JavaScript browser applications.
|
||||||
|
bool get isPublic => hashedSecret == null;
|
||||||
|
|
||||||
|
/// Whether or not this is a public or confidential client.
|
||||||
|
///
|
||||||
|
/// Confidential clients have a client secret that must be used when authenticating with
|
||||||
|
/// a client-authenticated request. Confidential clients are used when you can
|
||||||
|
/// be sure that the client secret cannot be viewed by anyone outside of the developer.
|
||||||
|
bool get isConfidential => hashedSecret != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return "AuthClient (${isPublic ? "public" : "confidental"}): $id $redirectURI";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents an OAuth 2.0 token.
|
||||||
|
///
|
||||||
|
/// [AuthServerDelegate] and [AuthServer] will exchange OAuth 2.0
|
||||||
|
/// tokens through instances of this type.
|
||||||
|
///
|
||||||
|
/// See the `package:conduit_core/managed_auth` library for a concrete implementation of this type.
|
||||||
|
class AuthToken {
|
||||||
|
/// The value to be passed as a Bearer Authorization header.
|
||||||
|
String? accessToken;
|
||||||
|
|
||||||
|
/// The value to be passed for refreshing a token.
|
||||||
|
String? refreshToken;
|
||||||
|
|
||||||
|
/// The time this token was issued on.
|
||||||
|
DateTime? issueDate;
|
||||||
|
|
||||||
|
/// The time when this token expires.
|
||||||
|
DateTime? expirationDate;
|
||||||
|
|
||||||
|
/// The type of token, currently only 'bearer' is valid.
|
||||||
|
String? type;
|
||||||
|
|
||||||
|
/// The identifier of the resource owner.
|
||||||
|
///
|
||||||
|
/// Tokens are owned by a resource owner, typically a User, Profile or Account
|
||||||
|
/// in an application. This value is the primary key or identifying value of those
|
||||||
|
/// instances.
|
||||||
|
int? resourceOwnerIdentifier;
|
||||||
|
|
||||||
|
/// The client ID this token was issued from.
|
||||||
|
late String clientID;
|
||||||
|
|
||||||
|
/// Scopes this token has access to.
|
||||||
|
List<AuthScope>? scopes;
|
||||||
|
|
||||||
|
/// Whether or not this token is expired by evaluated [expirationDate].
|
||||||
|
bool get isExpired {
|
||||||
|
return expirationDate!.difference(DateTime.now().toUtc()).inSeconds <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits this instance as a [Map] according to the OAuth 2.0 specification.
|
||||||
|
Map<String, dynamic> asMap() {
|
||||||
|
final map = {
|
||||||
|
"access_token": accessToken,
|
||||||
|
"token_type": type,
|
||||||
|
"expires_in":
|
||||||
|
expirationDate!.difference(DateTime.now().toUtc()).inSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (refreshToken != null) {
|
||||||
|
map["refresh_token"] = refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopes != null) {
|
||||||
|
map["scope"] = scopes!.map((s) => s.toString()).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents an OAuth 2.0 authorization code.
|
||||||
|
///
|
||||||
|
/// [AuthServerDelegate] and [AuthServer] will exchange OAuth 2.0
|
||||||
|
/// authorization codes through instances of this type.
|
||||||
|
///
|
||||||
|
/// See the conduit/managed_auth library for a concrete implementation of this type.
|
||||||
|
class AuthCode {
|
||||||
|
/// The actual one-time code used to exchange for tokens.
|
||||||
|
String? code;
|
||||||
|
|
||||||
|
/// The client ID the authorization code was issued under.
|
||||||
|
late String clientID;
|
||||||
|
|
||||||
|
/// The identifier of the resource owner.
|
||||||
|
///
|
||||||
|
/// Authorization codes are owned by a resource owner, typically a User, Profile or Account
|
||||||
|
/// in an application. This value is the primary key or identifying value of those
|
||||||
|
/// instances.
|
||||||
|
int? resourceOwnerIdentifier;
|
||||||
|
|
||||||
|
/// The timestamp this authorization code was issued on.
|
||||||
|
DateTime? issueDate;
|
||||||
|
|
||||||
|
/// When this authorization code expires, recommended for 10 minutes after issue date.
|
||||||
|
DateTime? expirationDate;
|
||||||
|
|
||||||
|
/// Whether or not this authorization code has already been exchanged for a token.
|
||||||
|
bool? hasBeenExchanged;
|
||||||
|
|
||||||
|
/// Scopes the exchanged token will have.
|
||||||
|
List<AuthScope>? requestedScopes;
|
||||||
|
|
||||||
|
/// Whether or not this code has expired yet, according to its [expirationDate].
|
||||||
|
bool get isExpired {
|
||||||
|
return expirationDate!.difference(DateTime.now().toUtc()).inSeconds <= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authorization information for a [Request] after it has passed through an [Authorizer].
|
||||||
|
///
|
||||||
|
/// After a request has passed through an [Authorizer], an instance of this type
|
||||||
|
/// is created and attached to the request (see [Request.authorization]). Instances of this type contain the information
|
||||||
|
/// that the [Authorizer] obtained from an [AuthValidator] (typically an [AuthServer])
|
||||||
|
/// about the validity of the credentials in a request.
|
||||||
|
class Authorization {
|
||||||
|
/// Creates an instance of a [Authorization].
|
||||||
|
Authorization(
|
||||||
|
this.clientID,
|
||||||
|
this.ownerID,
|
||||||
|
this.validator, {
|
||||||
|
this.credentials,
|
||||||
|
this.scopes,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The client ID the permission was granted under.
|
||||||
|
final String clientID;
|
||||||
|
|
||||||
|
/// The identifier for the owner of the resource, if provided.
|
||||||
|
///
|
||||||
|
/// If this instance refers to the authorization of a resource owner, this value will
|
||||||
|
/// be its identifying value. For example, in an application where a 'User' is stored in a database,
|
||||||
|
/// this value would be the primary key of that user.
|
||||||
|
///
|
||||||
|
/// If this authorization does not refer to a specific resource owner, this value will be null.
|
||||||
|
final int? ownerID;
|
||||||
|
|
||||||
|
/// The [AuthValidator] that granted this permission.
|
||||||
|
final AuthValidator? validator;
|
||||||
|
|
||||||
|
/// Basic authorization credentials, if provided.
|
||||||
|
///
|
||||||
|
/// If this instance represents the authorization header of a request with basic authorization credentials,
|
||||||
|
/// the parsed credentials will be available in this property. Otherwise, this value is null.
|
||||||
|
final AuthBasicCredentials? credentials;
|
||||||
|
|
||||||
|
/// The list of scopes this authorization has access to.
|
||||||
|
///
|
||||||
|
/// If the access token used to create this instance has scope,
|
||||||
|
/// those scopes will be available here. Otherwise, null.
|
||||||
|
List<AuthScope>? scopes;
|
||||||
|
|
||||||
|
/// Whether or not this instance has access to a specific scope.
|
||||||
|
///
|
||||||
|
/// This method checks each element in [scopes] for any that gives privileges
|
||||||
|
/// to access [scope].
|
||||||
|
bool isAuthorizedForScope(String scope) {
|
||||||
|
final asScope = AuthScope(scope);
|
||||||
|
return scopes?.any(asScope.isSubsetOrEqualTo) ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instances represent OAuth 2.0 scope.
|
||||||
|
///
|
||||||
|
/// An OAuth 2.0 token may optionally have authorization scopes. An authorization scope provides more granular
|
||||||
|
/// authorization to protected resources. Without authorization scopes, any valid token can pass through an
|
||||||
|
/// [Authorizer.bearer]. Scopes allow [Authorizer]s to restrict access to routes that do not have the
|
||||||
|
/// appropriate scope values.
|
||||||
|
///
|
||||||
|
/// An [AuthClient] has a list of valid scopes (see `conduit auth` tool). An access token issued for an [AuthClient] may ask for
|
||||||
|
/// any of the scopes the client provides. Scopes are then granted to the access token. An [Authorizer] may specify
|
||||||
|
/// a one or more required scopes that a token must have to pass to the next controller.
|
||||||
|
class AuthScope {
|
||||||
|
/// Creates an instance of this type from [scopeString].
|
||||||
|
///
|
||||||
|
/// A simple authorization scope string is a single keyword. Valid characters are
|
||||||
|
///
|
||||||
|
/// A-Za-z0-9!#\$%&'`()*+,./:;<=>?@[]^_{|}-.
|
||||||
|
///
|
||||||
|
/// For example, 'account' is a valid scope. An [Authorizer] can require an access token to have
|
||||||
|
/// the 'account' scope to pass through it. Access tokens without the 'account' scope are unauthorized.
|
||||||
|
///
|
||||||
|
/// More advanced scopes may contain multiple segments and a modifier. For example, the following are valid scopes:
|
||||||
|
///
|
||||||
|
/// user
|
||||||
|
/// user:settings
|
||||||
|
/// user:posts
|
||||||
|
/// user:posts.readonly
|
||||||
|
///
|
||||||
|
/// Segments are delimited by the colon character (`:`). Segments allow more granular scoping options. Each segment adds a
|
||||||
|
/// restriction to the segment prior to it. For example, the scope `user`
|
||||||
|
/// would allow all user actions, whereas `user:settings` would only allow access to a user's settings. Routes that are secured
|
||||||
|
/// to either `user:settings` or `user:posts.readonly` are accessible by an access token with `user` scope. A token with `user:settings`
|
||||||
|
/// would not be able to access a route limited to `user:posts`.
|
||||||
|
///
|
||||||
|
/// A modifier is an additional restrictive measure and follows scope segments and the dot character (`.`). A scope may only
|
||||||
|
/// have one modifier at the very end of the scope. A modifier can be any string, as long as its characters are in the above
|
||||||
|
/// list of valid characters. A modifier adds an additional restriction to a scope, without having to make up a new segment.
|
||||||
|
/// An example is the 'readonly' modifier above. A route that requires `user:posts.readonly` would allow passage when the token
|
||||||
|
/// has `user`, `user:posts` or `user:posts.readonly`. A route that required `user:posts` would not allow `user:posts.readonly`.
|
||||||
|
factory AuthScope(String scopeString) {
|
||||||
|
final cached = _cache[scopeString];
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
final scope = AuthScope._parse(scopeString);
|
||||||
|
_cache[scopeString] = scope;
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AuthScope._parse(String scopeString) {
|
||||||
|
if (scopeString.isEmpty) {
|
||||||
|
throw FormatException(
|
||||||
|
"Invalid AuthScope. May not an empty string.",
|
||||||
|
scopeString,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final c in scopeString.codeUnits) {
|
||||||
|
if (!(c == 33 || (c >= 35 && c <= 91) || (c >= 93 && c <= 126))) {
|
||||||
|
throw FormatException(
|
||||||
|
"Invalid authorization scope. May only contain "
|
||||||
|
"the following characters: A-Za-z0-9!#\$%&'`()*+,./:;<=>?@[]^_{|}-",
|
||||||
|
scopeString,
|
||||||
|
scopeString.codeUnits.indexOf(c),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final segments = _parseSegments(scopeString);
|
||||||
|
final lastModifier = segments.last.modifier;
|
||||||
|
|
||||||
|
return AuthScope._(scopeString, segments, lastModifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthScope._(this._scopeString, this._segments, this._lastModifier);
|
||||||
|
|
||||||
|
/// Signifies 'any' scope in [AuthServerDelegate.getAllowedScopes].
|
||||||
|
///
|
||||||
|
/// See [AuthServerDelegate.getAllowedScopes] for more details.
|
||||||
|
static const List<AuthScope> any = [
|
||||||
|
AuthScope._("_scope:_constant:_marker", [], null)
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Returns true if that [providedScopes] fulfills [requiredScopes].
|
||||||
|
///
|
||||||
|
/// For all [requiredScopes], there must be a scope in [requiredScopes] that meets or exceeds
|
||||||
|
/// that scope for this method to return true. If [requiredScopes] is null, this method
|
||||||
|
/// return true regardless of [providedScopes].
|
||||||
|
static bool verify(
|
||||||
|
List<AuthScope>? requiredScopes,
|
||||||
|
List<AuthScope>? providedScopes,
|
||||||
|
) {
|
||||||
|
if (requiredScopes == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return requiredScopes.every((requiredScope) {
|
||||||
|
final tokenHasValidScope = providedScopes
|
||||||
|
?.any((tokenScope) => requiredScope.isSubsetOrEqualTo(tokenScope));
|
||||||
|
|
||||||
|
return tokenHasValidScope ?? false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static final Map<String, AuthScope> _cache = {};
|
||||||
|
|
||||||
|
final String _scopeString;
|
||||||
|
|
||||||
|
/// Individual segments, separated by `:` character, of this instance.
|
||||||
|
///
|
||||||
|
/// Will always have a length of at least 1.
|
||||||
|
Iterable<String?> get segments => _segments.map((s) => s.name);
|
||||||
|
|
||||||
|
/// The modifier of this scope, if it exists.
|
||||||
|
///
|
||||||
|
/// If this instance does not have a modifier, returns null.
|
||||||
|
String? get modifier => _lastModifier;
|
||||||
|
|
||||||
|
final List<_AuthScopeSegment> _segments;
|
||||||
|
final String? _lastModifier;
|
||||||
|
|
||||||
|
static List<_AuthScopeSegment> _parseSegments(String scopeString) {
|
||||||
|
if (scopeString.isEmpty) {
|
||||||
|
throw FormatException(
|
||||||
|
"Invalid AuthScope. May not be empty string.",
|
||||||
|
scopeString,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final elements =
|
||||||
|
scopeString.split(":").map((seg) => _AuthScopeSegment(seg)).toList();
|
||||||
|
|
||||||
|
var scannedOffset = 0;
|
||||||
|
for (var i = 0; i < elements.length - 1; i++) {
|
||||||
|
if (elements[i].modifier != null) {
|
||||||
|
throw FormatException(
|
||||||
|
"Invalid AuthScope. May only contain modifiers on the last segment.",
|
||||||
|
scopeString,
|
||||||
|
scannedOffset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elements[i].name == "") {
|
||||||
|
throw FormatException(
|
||||||
|
"Invalid AuthScope. May not contain empty segments or, leading or trailing colons.",
|
||||||
|
scopeString,
|
||||||
|
scannedOffset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
scannedOffset += elements[i].toString().length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elements.last.name == "") {
|
||||||
|
throw FormatException(
|
||||||
|
"Invalid AuthScope. May not contain empty segments.",
|
||||||
|
scopeString,
|
||||||
|
scannedOffset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether or not this instance is a subset or equal to [incomingScope].
|
||||||
|
///
|
||||||
|
/// The scope `users:posts` is a subset of `users`.
|
||||||
|
///
|
||||||
|
/// This check is used to determine if an [Authorizer] can allow a [Request]
|
||||||
|
/// to pass if the [Request]'s [Request.authorization] has a scope that has
|
||||||
|
/// the same or more scope than the required scope of an [Authorizer].
|
||||||
|
bool isSubsetOrEqualTo(AuthScope incomingScope) {
|
||||||
|
if (incomingScope._lastModifier != null) {
|
||||||
|
// If the modifier of the incoming scope is restrictive,
|
||||||
|
// and this scope requires no restrictions, then it's not allowed.
|
||||||
|
if (_lastModifier == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the incoming scope's modifier doesn't match this one,
|
||||||
|
// then we also don't have access.
|
||||||
|
if (_lastModifier != incomingScope._lastModifier) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final thisIterator = _segments.iterator;
|
||||||
|
for (final incomingSegment in incomingScope._segments) {
|
||||||
|
// If the incoming scope is more restrictive than this scope,
|
||||||
|
// then it's not allowed.
|
||||||
|
if (!thisIterator.moveNext()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final current = thisIterator.current;
|
||||||
|
|
||||||
|
// If we have a mismatch here, then we're going
|
||||||
|
// down the wrong path.
|
||||||
|
if (incomingSegment.name != current.name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alias of [isSubsetOrEqualTo].
|
||||||
|
@Deprecated('Use AuthScope.isSubsetOrEqualTo() instead')
|
||||||
|
bool allowsScope(AuthScope incomingScope) => isSubsetOrEqualTo(incomingScope);
|
||||||
|
|
||||||
|
/// String variant of [isSubsetOrEqualTo].
|
||||||
|
///
|
||||||
|
/// Parses an instance of this type from [scopeString] and invokes
|
||||||
|
/// [isSubsetOrEqualTo].
|
||||||
|
bool allows(String scopeString) => isSubsetOrEqualTo(AuthScope(scopeString));
|
||||||
|
|
||||||
|
/// Whether or not two scopes are exactly the same.
|
||||||
|
bool isExactlyScope(AuthScope scope) {
|
||||||
|
final incomingIterator = scope._segments.iterator;
|
||||||
|
for (final segment in _segments) {
|
||||||
|
/// the scope has less segments so no match.
|
||||||
|
if (!incomingIterator.moveNext()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final incomingSegment = incomingIterator.current;
|
||||||
|
|
||||||
|
if (incomingSegment.name != segment.name ||
|
||||||
|
incomingSegment.modifier != segment.modifier) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// String variant of [isExactlyScope].
|
||||||
|
///
|
||||||
|
/// Parses an instance of this type from [scopeString] and invokes [isExactlyScope].
|
||||||
|
bool isExactly(String scopeString) {
|
||||||
|
return isExactlyScope(AuthScope(scopeString));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _scopeString;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthScopeSegment {
|
||||||
|
_AuthScopeSegment(String segment) {
|
||||||
|
final split = segment.split(".");
|
||||||
|
if (split.length == 2) {
|
||||||
|
name = split.first;
|
||||||
|
modifier = split.last;
|
||||||
|
} else {
|
||||||
|
name = segment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? name;
|
||||||
|
String? modifier;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
if (modifier == null) {
|
||||||
|
return name!;
|
||||||
|
}
|
||||||
|
return "$name.$modifier";
|
||||||
|
}
|
||||||
|
}
|
154
packages/auth/lib/src/protocols.dart
Normal file
154
packages/auth/lib/src/protocols.dart
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:protevus_auth/auth.dart';
|
||||||
|
|
||||||
|
/// The properties of an OAuth 2.0 Resource Owner.
|
||||||
|
///
|
||||||
|
/// Your application's 'user' type must implement the methods declared in this interface. [AuthServer] can
|
||||||
|
/// validate the credentials of a [ResourceOwner] to grant authorization codes and access tokens on behalf of that
|
||||||
|
/// owner.
|
||||||
|
abstract class ResourceOwner {
|
||||||
|
/// The username of the resource owner.
|
||||||
|
///
|
||||||
|
/// This value must be unique amongst all resource owners. It is often an email address. This value
|
||||||
|
/// is used by authenticating users to identify their account.
|
||||||
|
String? username;
|
||||||
|
|
||||||
|
/// The hashed password of this instance.
|
||||||
|
String? hashedPassword;
|
||||||
|
|
||||||
|
/// The salt the [hashedPassword] was hashed with.
|
||||||
|
String? salt;
|
||||||
|
|
||||||
|
/// A unique identifier of this resource owner.
|
||||||
|
///
|
||||||
|
/// This unique identifier is used by [AuthServer] to associate authorization codes and access tokens with
|
||||||
|
/// this resource owner.
|
||||||
|
int? get id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The methods used by an [AuthServer] to store information and customize behavior related to authorization.
|
||||||
|
///
|
||||||
|
/// An [AuthServer] requires an instance of this type to manage storage of [ResourceOwner]s, [AuthToken], [AuthCode],
|
||||||
|
/// and [AuthClient]s. You may also customize the token format or add more granular authorization scope rules.
|
||||||
|
///
|
||||||
|
/// Prefer to use `ManagedAuthDelegate` from 'package:conduit_core/managed_auth.dart' instead of implementing this interface;
|
||||||
|
/// there are important details to consider and test when implementing this interface.
|
||||||
|
abstract class AuthServerDelegate {
|
||||||
|
/// Must return a [ResourceOwner] for a [username].
|
||||||
|
///
|
||||||
|
/// This method must return an instance of [ResourceOwner] if one exists for [username]. Otherwise, it must return null.
|
||||||
|
///
|
||||||
|
/// Every property declared by [ResourceOwner] must be non-null in the return value.
|
||||||
|
///
|
||||||
|
/// [server] is the [AuthServer] invoking this method.
|
||||||
|
FutureOr<ResourceOwner?> getResourceOwner(AuthServer server, String username);
|
||||||
|
|
||||||
|
/// Must store [client].
|
||||||
|
///
|
||||||
|
/// [client] must be returned by [getClient] after this method has been invoked, and until (if ever)
|
||||||
|
/// [removeClient] is invoked.
|
||||||
|
FutureOr addClient(AuthServer server, AuthClient client);
|
||||||
|
|
||||||
|
/// Must return [AuthClient] for a client ID.
|
||||||
|
///
|
||||||
|
/// This method must return an instance of [AuthClient] if one exists for [clientID]. Otherwise, it must return null.
|
||||||
|
/// [server] is the [AuthServer] requesting the [AuthClient].
|
||||||
|
FutureOr<AuthClient?> getClient(AuthServer server, String clientID);
|
||||||
|
|
||||||
|
/// Removes an [AuthClient] for a client ID.
|
||||||
|
///
|
||||||
|
/// This method must delete the [AuthClient] for [clientID]. Subsequent requests to this
|
||||||
|
/// instance for [getClient] must return null after this method completes. If there is no
|
||||||
|
/// matching [clientID], this method may choose whether to throw an exception or fail silently.
|
||||||
|
///
|
||||||
|
/// [server] is the [AuthServer] requesting the [AuthClient].
|
||||||
|
FutureOr removeClient(AuthServer server, String clientID);
|
||||||
|
|
||||||
|
/// Returns a [AuthToken] searching by its access token or refresh token.
|
||||||
|
///
|
||||||
|
/// Exactly one of [byAccessToken] and [byRefreshToken] may be non-null, if not, this method must throw an error.
|
||||||
|
///
|
||||||
|
/// If [byAccessToken] is not-null and there exists a matching [AuthToken.accessToken], return that token.
|
||||||
|
/// If [byRefreshToken] is not-null and there exists a matching [AuthToken.refreshToken], return that token.
|
||||||
|
///
|
||||||
|
/// If no match is found, return null.
|
||||||
|
///
|
||||||
|
/// [server] is the [AuthServer] requesting the [AuthToken].
|
||||||
|
FutureOr<AuthToken?> getToken(
|
||||||
|
AuthServer server, {
|
||||||
|
String? byAccessToken,
|
||||||
|
String? byRefreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// This method must delete all [AuthToken] and [AuthCode]s for a [ResourceOwner].
|
||||||
|
///
|
||||||
|
/// [server] is the requesting [AuthServer]. [resourceOwnerID] is the [ResourceOwner.id].
|
||||||
|
FutureOr removeTokens(AuthServer server, int resourceOwnerID);
|
||||||
|
|
||||||
|
/// Must delete a [AuthToken] granted by [grantedByCode].
|
||||||
|
///
|
||||||
|
/// If an [AuthToken] has been granted by exchanging [AuthCode], that token must be revoked
|
||||||
|
/// and can no longer be used to authorize access to a resource. [grantedByCode] should
|
||||||
|
/// also be removed.
|
||||||
|
///
|
||||||
|
/// This method is invoked when attempting to exchange an authorization code that has already granted a token.
|
||||||
|
FutureOr removeToken(AuthServer server, AuthCode grantedByCode);
|
||||||
|
|
||||||
|
/// Must store [token].
|
||||||
|
///
|
||||||
|
/// [token] must be stored such that it is accessible from [getToken], and until it is either
|
||||||
|
/// revoked via [removeToken] or [removeTokens], or until it has expired and can reasonably
|
||||||
|
/// be believed to no longer be in use.
|
||||||
|
///
|
||||||
|
/// You may alter [token] prior to storing it. This may include replacing [AuthToken.accessToken] with another token
|
||||||
|
/// format. The default token format will be a random 32 character string.
|
||||||
|
///
|
||||||
|
/// If this token was granted through an authorization code, [issuedFrom] is that code. Otherwise, [issuedFrom]
|
||||||
|
/// is null.
|
||||||
|
FutureOr addToken(AuthServer server, AuthToken token, {AuthCode? issuedFrom});
|
||||||
|
|
||||||
|
/// Must update [AuthToken] with [newAccessToken, [newIssueDate, [newExpirationDate].
|
||||||
|
///
|
||||||
|
/// This method must must update an existing [AuthToken], found by [oldAccessToken],
|
||||||
|
/// with the values [newAccessToken], [newIssueDate] and [newExpirationDate].
|
||||||
|
///
|
||||||
|
/// You may alter the token in addition to the provided values, and you may override the provided values.
|
||||||
|
/// [newAccessToken] defaults to a random 32 character string.
|
||||||
|
FutureOr updateToken(
|
||||||
|
AuthServer server,
|
||||||
|
String? oldAccessToken,
|
||||||
|
String? newAccessToken,
|
||||||
|
DateTime? newIssueDate,
|
||||||
|
DateTime? newExpirationDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Must store [code].
|
||||||
|
///
|
||||||
|
/// [code] must be accessible until its expiration date.
|
||||||
|
FutureOr addCode(AuthServer server, AuthCode code);
|
||||||
|
|
||||||
|
/// Must return [AuthCode] for its identifiying [code].
|
||||||
|
///
|
||||||
|
/// This must return an instance of [AuthCode] where [AuthCode.code] matches [code].
|
||||||
|
/// Return null if no matching code.
|
||||||
|
FutureOr<AuthCode?> getCode(AuthServer server, String code);
|
||||||
|
|
||||||
|
/// Must remove [AuthCode] identified by [code].
|
||||||
|
///
|
||||||
|
/// The [AuthCode.code] matching [code] must be deleted and no longer accessible.
|
||||||
|
FutureOr removeCode(AuthServer server, String? code);
|
||||||
|
|
||||||
|
/// Returns list of allowed scopes for a given [ResourceOwner].
|
||||||
|
///
|
||||||
|
/// Subclasses override this method to return a list of [AuthScope]s based on some attribute(s) of an [ResourceOwner].
|
||||||
|
/// That [ResourceOwner] is then restricted to only those scopes, even if the authenticating client would allow other scopes
|
||||||
|
/// or scopes with higher privileges.
|
||||||
|
///
|
||||||
|
/// By default, this method returns [AuthScope.any] - any [ResourceOwner] being authenticated has full access to the scopes
|
||||||
|
/// available to the authenticating client.
|
||||||
|
///
|
||||||
|
/// When overriding this method, it is important to note that (by default) only the properties declared by [ResourceOwner]
|
||||||
|
/// will be valid for [owner]. If [owner] has properties that are application-specific (like a `role`),
|
||||||
|
/// [getResourceOwner] must also be overridden to ensure those values are fetched.
|
||||||
|
List<AuthScope>? getAllowedScopes(ResourceOwner owner) => AuthScope.any;
|
||||||
|
}
|
41
packages/auth/lib/src/validator.dart
Normal file
41
packages/auth/lib/src/validator.dart
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:protevus_openapi/documentable.dart';
|
||||||
|
import 'package:protevus_auth/auth.dart';
|
||||||
|
import 'package:protevus_http/http.dart';
|
||||||
|
import 'package:protevus_openapi/v3.dart';
|
||||||
|
|
||||||
|
/// Instances that implement this type can be used by an [Authorizer] to determine authorization of a request.
|
||||||
|
///
|
||||||
|
/// When an [Authorizer] processes a [Request], it invokes [validate], passing in the parsed Authorization
|
||||||
|
/// header of the [Request].
|
||||||
|
///
|
||||||
|
/// [AuthServer] implements this interface.
|
||||||
|
mixin AuthValidator {
|
||||||
|
/// Returns an [Authorization] if [authorizationData] is valid.
|
||||||
|
///
|
||||||
|
/// This method is invoked by [Authorizer] to validate the Authorization header of a request. [authorizationData]
|
||||||
|
/// is the parsed contents of the Authorization header, while [parser] is the object that parsed the header.
|
||||||
|
///
|
||||||
|
/// If this method returns null, an [Authorizer] will send a 401 Unauthorized response.
|
||||||
|
/// If this method throws an [AuthorizationParserException], a 400 Bad Request response is sent.
|
||||||
|
/// If this method throws an [AuthServerException], an appropriate status code is sent for the details of the exception.
|
||||||
|
///
|
||||||
|
/// If [requiredScope] is provided, a request's authorization must have at least that much scope to pass the [Authorizer].
|
||||||
|
FutureOr<Authorization?> validate<T>(
|
||||||
|
AuthorizationParser<T> parser,
|
||||||
|
T authorizationData, {
|
||||||
|
List<AuthScope>? requiredScope,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Provide [APISecurityRequirement]s for [authorizer].
|
||||||
|
///
|
||||||
|
/// An [Authorizer] that adds security requirements to operations will invoke this method to allow this validator to define those requirements.
|
||||||
|
/// The [Authorizer] must provide the [context] it was given to document the operations, itself and optionally a list of [scopes] required to pass it.
|
||||||
|
List<APISecurityRequirement> documentRequirementsForAuthorizer(
|
||||||
|
APIDocumentContext context,
|
||||||
|
Authorizer authorizer, {
|
||||||
|
List<AuthScope>? scopes,
|
||||||
|
}) =>
|
||||||
|
[];
|
||||||
|
}
|
|
@ -10,6 +10,10 @@ environment:
|
||||||
|
|
||||||
# Add regular dependencies here.
|
# Add regular dependencies here.
|
||||||
dependencies:
|
dependencies:
|
||||||
|
protevus_http: ^0.0.1
|
||||||
|
protevus_openapi: ^0.0.1
|
||||||
|
protevus_hashing: ^0.0.1
|
||||||
|
crypto: ^3.0.3
|
||||||
# path: ^1.8.0
|
# path: ^1.8.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|
Loading…
Reference in a new issue