add(conduit): refactoring conduit core

This commit is contained in:
Patrick Stewart 2024-09-03 13:15:08 -07:00
parent fe9ccd7d7c
commit bbc1e48740
13 changed files with 2868 additions and 0 deletions

View 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';

View 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);
}

View 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,
);
}
}

View 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)},
);
}
}

View 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,
);
}
}

View 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;
}

View 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();
}

View 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;
}
}

View 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
}

View 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";
}
}

View 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;
}

View 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,
}) =>
[];
}

View file

@ -10,6 +10,10 @@ environment:
# Add regular dependencies here.
dependencies:
protevus_http: ^0.0.1
protevus_openapi: ^0.0.1
protevus_hashing: ^0.0.1
crypto: ^3.0.3
# path: ^1.8.0
dev_dependencies: