From bbc1e48740c1c5f97272c42fa2132a3ff5b979aa Mon Sep 17 00:00:00 2001 From: Patrick Stewart Date: Tue, 3 Sep 2024 13:15:08 -0700 Subject: [PATCH] add(conduit): refactoring conduit core --- packages/auth/lib/auth.dart | 22 + packages/auth/lib/src/auth.dart | 70 ++ .../auth/lib/src/auth_code_controller.dart | 298 ++++++++ packages/auth/lib/src/auth_controller.dart | 226 ++++++ .../lib/src/auth_redirect_controller.dart | 384 ++++++++++ .../auth/lib/src/authorization_parser.dart | 111 +++ .../auth/lib/src/authorization_server.dart | 668 ++++++++++++++++++ packages/auth/lib/src/authorizer.dart | 228 ++++++ packages/auth/lib/src/exceptions.dart | 121 ++++ packages/auth/lib/src/objects.dart | 541 ++++++++++++++ packages/auth/lib/src/protocols.dart | 154 ++++ packages/auth/lib/src/validator.dart | 41 ++ packages/auth/pubspec.yaml | 4 + 13 files changed, 2868 insertions(+) create mode 100644 packages/auth/lib/auth.dart create mode 100644 packages/auth/lib/src/auth.dart create mode 100644 packages/auth/lib/src/auth_code_controller.dart create mode 100644 packages/auth/lib/src/auth_controller.dart create mode 100644 packages/auth/lib/src/auth_redirect_controller.dart create mode 100644 packages/auth/lib/src/authorization_parser.dart create mode 100644 packages/auth/lib/src/authorization_server.dart create mode 100644 packages/auth/lib/src/authorizer.dart create mode 100644 packages/auth/lib/src/exceptions.dart create mode 100644 packages/auth/lib/src/objects.dart create mode 100644 packages/auth/lib/src/protocols.dart create mode 100644 packages/auth/lib/src/validator.dart diff --git a/packages/auth/lib/auth.dart b/packages/auth/lib/auth.dart new file mode 100644 index 0000000..0da5fe6 --- /dev/null +++ b/packages/auth/lib/auth.dart @@ -0,0 +1,22 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * + * 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'; diff --git a/packages/auth/lib/src/auth.dart b/packages/auth/lib/src/auth.dart new file mode 100644 index 0000000..57f2c62 --- /dev/null +++ b/packages/auth/lib/src/auth.dart @@ -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); +} diff --git a/packages/auth/lib/src/auth_code_controller.dart b/packages/auth/lib/src/auth_code_controller.dart new file mode 100644 index 0000000..12bacb2 --- /dev/null +++ b/packages/auth/lib/src/auth_code_controller.dart @@ -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 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 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 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 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 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 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.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, + ); + } +} diff --git a/packages/auth/lib/src/auth_controller.dart b/packages/auth/lib/src/auth_controller.dart new file mode 100644 index 0000000..776aae1 --- /dev/null +++ b/packages/auth/lib/src/auth_controller.dart @@ -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 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 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 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 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)}, + ); + } +} diff --git a/packages/auth/lib/src/auth_redirect_controller.dart b/packages/auth/lib/src/auth_redirect_controller.dart new file mode 100644 index 0000000..f1234d9 --- /dev/null +++ b/packages/auth/lib/src/auth_redirect_controller.dart @@ -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 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: "

Error

unsupported_response_type

", + )..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 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 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 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 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 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.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, + ); + } +} diff --git a/packages/auth/lib/src/authorization_parser.dart b/packages/auth/lib/src/authorization_parser.dart new file mode 100644 index 0000000..06e318a --- /dev/null +++ b/packages/auth/lib/src/authorization_parser.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; + +abstract class AuthorizationParser { + const AuthorizationParser(); + + T parse(String authorizationHeader); +} + +/// Parses a Bearer token from an Authorization header. +class AuthorizationBearerParser extends AuthorizationParser { + 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 { + 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; +} diff --git a/packages/auth/lib/src/authorization_server.dart b/packages/auth/lib/src/authorization_server.dart new file mode 100644 index 0000000..6da3cfc --- /dev/null +++ b/packages/auth/lib/src/authorization_server.dart @@ -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(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 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 authenticate( + String? username, + String? password, + String clientID, + String? clientSecret, { + Duration expiration = const Duration(hours: 24), + List? 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 verify( + String? accessToken, { + List? 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 refresh( + String? refreshToken, + String clientID, + String? clientSecret, { + List? 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 authenticateForCode( + String? username, + String? password, + String clientID, { + int expirationInSeconds = 600, + List? 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 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 documentRequirementsForAuthorizer( + APIDocumentContext context, + Authorizer authorizer, { + List? 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 validate( + AuthorizationParser parser, + T authorizationData, { + List? 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 _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? _validatedScopes( + AuthClient client, + ResourceOwner authenticatable, + List? requestedScopes, + ) { + List? 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? 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? 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(); +} diff --git a/packages/auth/lib/src/authorizer.dart b/packages/auth/lib/src/authorizer.dart new file mode 100644 index 0000000..7b50145 --- /dev/null +++ b/packages/auth/lib/src/authorizer.dart @@ -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? 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? 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? 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 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; + 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 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; + } +} diff --git a/packages/auth/lib/src/exceptions.dart b/packages/auth/lib/src/exceptions.dart new file mode 100644 index 0000000..cabab92 --- /dev/null +++ b/packages/auth/lib/src/exceptions.dart @@ -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 +} diff --git a/packages/auth/lib/src/objects.dart b/packages/auth/lib/src/objects.dart new file mode 100644 index 0000000..b49a2d0 --- /dev/null +++ b/packages/auth/lib/src/objects.dart @@ -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? allowedScopes, + }) : this.withRedirectURI( + id, + hashedSecret, + salt, + null, + allowedScopes: allowedScopes, + ); + + /// Creates an instance of a public [AuthClient]. + AuthClient.public(String id, + {List? 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? allowedScopes, + }) { + this.allowedScopes = allowedScopes; + } + + List? _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? get allowedScopes => _allowedScopes; + set allowedScopes(List? 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? 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 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? 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? 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 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? requiredScopes, + List? providedScopes, + ) { + if (requiredScopes == null) { + return true; + } + + return requiredScopes.every((requiredScope) { + final tokenHasValidScope = providedScopes + ?.any((tokenScope) => requiredScope.isSubsetOrEqualTo(tokenScope)); + + return tokenHasValidScope ?? false; + }); + } + + static final Map _cache = {}; + + final String _scopeString; + + /// Individual segments, separated by `:` character, of this instance. + /// + /// Will always have a length of at least 1. + Iterable 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"; + } +} diff --git a/packages/auth/lib/src/protocols.dart b/packages/auth/lib/src/protocols.dart new file mode 100644 index 0000000..e61451c --- /dev/null +++ b/packages/auth/lib/src/protocols.dart @@ -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 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 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 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 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? getAllowedScopes(ResourceOwner owner) => AuthScope.any; +} diff --git a/packages/auth/lib/src/validator.dart b/packages/auth/lib/src/validator.dart new file mode 100644 index 0000000..2a89ea5 --- /dev/null +++ b/packages/auth/lib/src/validator.dart @@ -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 validate( + AuthorizationParser parser, + T authorizationData, { + List? 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 documentRequirementsForAuthorizer( + APIDocumentContext context, + Authorizer authorizer, { + List? scopes, + }) => + []; +} diff --git a/packages/auth/pubspec.yaml b/packages/auth/pubspec.yaml index a5f6a73..e9066e4 100644 --- a/packages/auth/pubspec.yaml +++ b/packages/auth/pubspec.yaml @@ -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: