+10
This commit is contained in:
parent
afb554fba0
commit
5b7e017f31
16 changed files with 195 additions and 161 deletions
|
@ -4,6 +4,7 @@
|
|||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.pub" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/test/packages" />
|
||||
|
|
|
@ -25,14 +25,4 @@
|
|||
</profile-state>
|
||||
</entry>
|
||||
</component>
|
||||
<component name="ProjectLevelVcsManager" settingsEditedManually="false">
|
||||
<OptionsSetting value="true" id="Add" />
|
||||
<OptionsSetting value="true" id="Remove" />
|
||||
<OptionsSetting value="true" id="Checkout" />
|
||||
<OptionsSetting value="true" id="Update" />
|
||||
<OptionsSetting value="true" id="Status" />
|
||||
<OptionsSetting value="true" id="Edit" />
|
||||
<ConfirmationsSetting value="0" id="Add" />
|
||||
<ConfirmationsSetting value="0" id="Remove" />
|
||||
</component>
|
||||
</project>
|
|
@ -1,6 +1,7 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="All Tests" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true">
|
||||
<option name="filePath" value="$PROJECT_DIR$/test/all_tests.dart" />
|
||||
<option name="filePath" value="$PROJECT_DIR$/test" />
|
||||
<option name="scope" value="FOLDER" />
|
||||
<method />
|
||||
</configuration>
|
||||
</component>
|
|
@ -1,6 +1,6 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Auth Token Tests" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true">
|
||||
<option name="filePath" value="$PROJECT_DIR$/test/auth_token.dart" />
|
||||
<option name="filePath" value="$PROJECT_DIR$/test/auth_token_test.dart" />
|
||||
<method />
|
||||
</configuration>
|
||||
</component>
|
|
@ -1,6 +1,6 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Local Tests" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true">
|
||||
<option name="filePath" value="$PROJECT_DIR$/test/local.dart" />
|
||||
<option name="filePath" value="$PROJECT_DIR$/test/local_test.dart" />
|
||||
<method />
|
||||
</configuration>
|
||||
</component>
|
1
.travis.yml
Normal file
1
.travis.yml
Normal file
|
@ -0,0 +1 @@
|
|||
language: dart
|
|
@ -1,8 +1,12 @@
|
|||
# angel_auth
|
||||
|
||||
![version 1.1.0-dev+10](https://img.shields.io/badge/version-1.1.0--dev+10-red.svg)
|
||||
![build status](https://travis-ci.org/angel-dart/auth.svg?branch=master)
|
||||
|
||||
A complete authentication plugin for Angel. Inspired by Passport.
|
||||
|
||||
# Documentation
|
||||
Coming soon!
|
||||
[Click here](https://github.com/angel-dart/auth/wiki).
|
||||
|
||||
# Supported Strategies
|
||||
* Local (with and without Basic Auth)
|
|
@ -1,5 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
|
||||
|
@ -16,7 +15,7 @@ class RequireAuthorizationMiddleware extends BaseMiddleware {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (req.properties.containsKey('user'))
|
||||
if (req.properties.containsKey('user') || req.method == 'OPTIONS')
|
||||
return true;
|
||||
else
|
||||
return _reject(res);
|
||||
|
|
|
@ -18,6 +18,8 @@ class AngelAuth extends AngelPlugin {
|
|||
final RegExp _rgxBearer = new RegExp(r"^Bearer");
|
||||
RequireAuthorizationMiddleware _requireAuth =
|
||||
new RequireAuthorizationMiddleware();
|
||||
final bool allowCookie;
|
||||
final bool allowTokenInQuery;
|
||||
String middlewareName;
|
||||
bool debug;
|
||||
bool enforceIp;
|
||||
|
@ -40,6 +42,8 @@ class AngelAuth extends AngelPlugin {
|
|||
AngelAuth(
|
||||
{String jwtKey,
|
||||
num jwtLifeSpan,
|
||||
this.allowCookie: true,
|
||||
this.allowTokenInQuery: true,
|
||||
this.debug: false,
|
||||
this.enforceIp: true,
|
||||
this.middlewareName: 'auth',
|
||||
|
@ -54,49 +58,74 @@ class AngelAuth extends AngelPlugin {
|
|||
app.container.singleton(this);
|
||||
if (runtimeType != AngelAuth) app.container.singleton(this, as: AngelAuth);
|
||||
|
||||
app.before.add(_decodeJwt);
|
||||
app.before.add(decodeJwt);
|
||||
app.registerMiddleware(middlewareName, _requireAuth);
|
||||
|
||||
if (reviveTokenEndpoint != null) {
|
||||
app.post(reviveTokenEndpoint, _reviveJwt);
|
||||
app.post(reviveTokenEndpoint, reviveJwt);
|
||||
}
|
||||
}
|
||||
|
||||
_decodeJwt(RequestContext req, ResponseContext res) async {
|
||||
decodeJwt(RequestContext req, ResponseContext res) async {
|
||||
if (req.method == "POST" && req.path == reviveTokenEndpoint) {
|
||||
// Shouldn't block invalid JWT if we are reviving it
|
||||
|
||||
if (debug)
|
||||
print('Token revival endpoint accessed.');
|
||||
|
||||
return await _reviveJwt(req, res);
|
||||
if (debug) print('Token revival endpoint accessed.');
|
||||
return await reviveJwt(req, res);
|
||||
}
|
||||
|
||||
String jwt = _getJwt(req);
|
||||
if (debug) {
|
||||
print('Enforcing JWT authentication...');
|
||||
}
|
||||
|
||||
String jwt = getJwt(req);
|
||||
|
||||
if (debug) {
|
||||
print('Found JWT: $jwt');
|
||||
}
|
||||
|
||||
if (jwt != null) {
|
||||
var token = new AuthToken.validate(jwt, _hs256);
|
||||
|
||||
if (debug) {
|
||||
print('Decoded auth token: ${token.toJson()}');
|
||||
}
|
||||
|
||||
if (enforceIp) {
|
||||
if (req.ip != token.ipAddress)
|
||||
if (debug) {
|
||||
print(
|
||||
'Token IP: ${token.ipAddress}. Current request sent from: ${req.ip}');
|
||||
}
|
||||
|
||||
if (req.ip != null && req.ip != token.ipAddress)
|
||||
throw new AngelHttpException.Forbidden(
|
||||
message: "JWT cannot be accessed from this IP address.");
|
||||
}
|
||||
|
||||
if (token.lifeSpan > -1) {
|
||||
if (debug) {
|
||||
print("Making sure this token hasn't already expired...");
|
||||
}
|
||||
|
||||
token.issuedAt.add(new Duration(milliseconds: token.lifeSpan));
|
||||
|
||||
if (!token.issuedAt.isAfter(new DateTime.now()))
|
||||
throw new AngelHttpException.Forbidden(message: "Expired JWT.");
|
||||
} else if (debug) {
|
||||
print('This token has an infinite life span.');
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
print('Now deserializing from this userId: ${token.userId}');
|
||||
}
|
||||
|
||||
req.inject(AuthToken, req.properties['token'] = token);
|
||||
req.properties["user"] = await deserializer(token.userId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_getJwt(RequestContext req) {
|
||||
getJwt(RequestContext req) {
|
||||
if (debug) {
|
||||
print('Attempting to parse JWT');
|
||||
}
|
||||
|
@ -106,39 +135,40 @@ class AngelAuth extends AngelPlugin {
|
|||
print('Found Auth header');
|
||||
}
|
||||
|
||||
return req.headers
|
||||
.value("Authorization")
|
||||
.replaceAll(_rgxBearer, "")
|
||||
.trim();
|
||||
final authHeader = req.headers.value("Authorization");
|
||||
|
||||
// Allow Basic auth to fall through
|
||||
if (_rgxBearer.hasMatch(authHeader))
|
||||
return authHeader.replaceAll(_rgxBearer, "").trim();
|
||||
} else if (req.cookies.any((cookie) => cookie.name == "token")) {
|
||||
print('Request has "token" cookie...');
|
||||
return req.cookies.firstWhere((cookie) => cookie.name == "token").value;
|
||||
} else if (allowTokenInQuery && req.query['token'] is String) {
|
||||
return req.query['token'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_reviveJwt(RequestContext req, ResponseContext res) async {
|
||||
reviveJwt(RequestContext req, ResponseContext res) async {
|
||||
try {
|
||||
if (debug)
|
||||
print('Attempting to revive JWT...');
|
||||
if (debug) print('Attempting to revive JWT...');
|
||||
|
||||
var jwt = _getJwt(req);
|
||||
var jwt = getJwt(req);
|
||||
|
||||
if (debug)
|
||||
print('Found JWT: $jwt');
|
||||
if (debug) print('Found JWT: $jwt');
|
||||
|
||||
if (jwt == null) {
|
||||
throw new AngelHttpException.Forbidden(message: "No JWT provided");
|
||||
} else {
|
||||
var token = new AuthToken.validate(jwt, _hs256);
|
||||
|
||||
if (debug)
|
||||
print('Validated and deserialized: $token');
|
||||
if (debug) print('Validated and deserialized: $token');
|
||||
|
||||
if (enforceIp) {
|
||||
if (debug)
|
||||
print('Token IP: ${token.ipAddress}. Current request sent from: ${req.ip}');
|
||||
print(
|
||||
'Token IP: ${token.ipAddress}. Current request sent from: ${req.ip}');
|
||||
|
||||
if (req.ip != token.ipAddress)
|
||||
throw new AngelHttpException.Forbidden(
|
||||
|
@ -147,19 +177,21 @@ class AngelAuth extends AngelPlugin {
|
|||
|
||||
if (token.lifeSpan > -1) {
|
||||
if (debug) {
|
||||
print('Checking if token has expired... Life span is ${token.lifeSpan}');
|
||||
print(
|
||||
'Checking if token has expired... Life span is ${token.lifeSpan}');
|
||||
}
|
||||
|
||||
token.issuedAt.add(new Duration(milliseconds: token.lifeSpan));
|
||||
|
||||
if (!token.issuedAt.isAfter(new DateTime.now())) {
|
||||
print('Token has indeed expired! Resetting assignment date to current timestamp...');
|
||||
print(
|
||||
'Token has indeed expired! Resetting assignment date to current timestamp...');
|
||||
// Extend its lifespan by changing iat
|
||||
token.issuedAt = new DateTime.now();
|
||||
} else if (debug) {
|
||||
print('Token has not expired yet.');
|
||||
}
|
||||
} else if(debug) {
|
||||
} else if (debug) {
|
||||
print('This token never expires, so it is still valid.');
|
||||
}
|
||||
|
||||
|
@ -193,9 +225,12 @@ class AngelAuth extends AngelPlugin {
|
|||
var userId = await serializer(result);
|
||||
|
||||
// Create JWT
|
||||
var jwt = new AuthToken(userId: userId, lifeSpan: _jwtLifeSpan)
|
||||
var jwt = new AuthToken(
|
||||
userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip)
|
||||
.serialize(_hs256);
|
||||
req.cookies.add(new Cookie("token", jwt));
|
||||
req.inject(AuthToken, jwt);
|
||||
|
||||
if (allowCookie) req.cookies.add(new Cookie("token", jwt));
|
||||
|
||||
if (req.headers.value("accept") != null &&
|
||||
(req.headers.value("accept").contains("application/json") ||
|
||||
|
|
|
@ -21,8 +21,8 @@ class LocalAuthStrategy extends AuthStrategy {
|
|||
String usernameField;
|
||||
String passwordField;
|
||||
String invalidMessage;
|
||||
bool allowBasic;
|
||||
bool forceBasic;
|
||||
final bool allowBasic;
|
||||
final bool forceBasic;
|
||||
String realm;
|
||||
|
||||
LocalAuthStrategy(LocalAuthVerifier this.verifier,
|
||||
|
@ -32,7 +32,8 @@ class LocalAuthStrategy extends AuthStrategy {
|
|||
'Please provide a valid username and password.',
|
||||
bool this.allowBasic: true,
|
||||
bool this.forceBasic: false,
|
||||
String this.realm: 'Authentication is required.'}) {}
|
||||
String this.realm: 'Authentication is required.'}) {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> canLogout(RequestContext req, ResponseContext res) async {
|
||||
|
@ -47,6 +48,7 @@ class LocalAuthStrategy extends AuthStrategy {
|
|||
|
||||
if (allowBasic) {
|
||||
String authHeader = req.headers.value(HttpHeaders.AUTHORIZATION) ?? "";
|
||||
|
||||
if (_rgxBasic.hasMatch(authHeader)) {
|
||||
String base64AuthString = _rgxBasic.firstMatch(authHeader).group(1);
|
||||
String authString =
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
name: angel_auth
|
||||
description: A complete authentication plugin for Angel.
|
||||
version: 1.0.0-dev+9
|
||||
version: 1.0.0-dev+10
|
||||
author: Tobe O <thosakwe@gmail.com>
|
||||
homepage: https://github.com/angel-dart/angel_auth
|
||||
dependencies:
|
||||
|
|
|
@ -3,8 +3,8 @@ import 'package:angel_framework/angel_framework.dart';
|
|||
import 'package:angel_auth/angel_auth.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:test/test.dart';
|
||||
import 'auth_token.dart' as authToken;
|
||||
import 'local.dart' as local;
|
||||
import 'auth_token_test.dart' as authToken;
|
||||
import 'local_test.dart' as local;
|
||||
|
||||
wireAuth(Angel app) async {
|
||||
|
109
test/local.dart
109
test/local.dart
|
@ -1,109 +0,0 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_auth/angel_auth.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:merge_map/merge_map.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
final AngelAuth Auth = new AngelAuth();
|
||||
Map headers = {HttpHeaders.ACCEPT: ContentType.JSON.mimeType};
|
||||
AngelAuthOptions localOpts = new AngelAuthOptions(
|
||||
failureRedirect: '/failure', successRedirect: '/success');
|
||||
Map sampleUser = {'hello': 'world'};
|
||||
|
||||
verifier(username, password) async {
|
||||
if (username == 'username' && password == 'password') {
|
||||
return sampleUser;
|
||||
} else
|
||||
return false;
|
||||
}
|
||||
|
||||
wireAuth(Angel app) async {
|
||||
Auth.serializer = (user) async => 1337;
|
||||
Auth.deserializer = (id) async => sampleUser;
|
||||
|
||||
Auth.strategies.add(new LocalAuthStrategy(verifier));
|
||||
await app.configure(Auth);
|
||||
}
|
||||
|
||||
main() async {
|
||||
group('local', () {
|
||||
Angel app;
|
||||
http.Client client;
|
||||
String url;
|
||||
String basicAuthUrl;
|
||||
|
||||
setUp(() async {
|
||||
client = new http.Client();
|
||||
app = new Angel();
|
||||
await app.configure(wireAuth);
|
||||
app.get('/hello', 'Woo auth', middleware: [Auth.authenticate('local')]);
|
||||
app.post('/login', 'This should not be shown',
|
||||
middleware: [Auth.authenticate('local', localOpts)]);
|
||||
app.get('/success', "yep", middleware: ['auth']);
|
||||
app.get('/failure', "nope");
|
||||
|
||||
HttpServer server =
|
||||
await app.startServer(InternetAddress.LOOPBACK_IP_V4, 0);
|
||||
url = "http://${server.address.host}:${server.port}";
|
||||
basicAuthUrl =
|
||||
"http://username:password@${server.address.host}:${server.port}";
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await app.httpServer.close(force: true);
|
||||
client = null;
|
||||
url = null;
|
||||
basicAuthUrl = null;
|
||||
});
|
||||
|
||||
test('can use "auth" as middleware', () async {
|
||||
var response = await client
|
||||
.get("$url/success", headers: {'Accept': 'application/json'});
|
||||
print(response.body);
|
||||
expect(response.statusCode, equals(403));
|
||||
});
|
||||
|
||||
test('successRedirect', () async {
|
||||
Map postData = {'username': 'username', 'password': 'password'};
|
||||
var response = await client.post("$url/login",
|
||||
body: JSON.encode(postData),
|
||||
headers: {HttpHeaders.CONTENT_TYPE: ContentType.JSON.mimeType});
|
||||
expect(response.statusCode, equals(200));
|
||||
expect(response.headers[HttpHeaders.LOCATION], equals('/success'));
|
||||
});
|
||||
|
||||
test('failureRedirect', () async {
|
||||
Map postData = {'username': 'password', 'password': 'username'};
|
||||
var response = await client.post("$url/login",
|
||||
body: JSON.encode(postData),
|
||||
headers: {HttpHeaders.CONTENT_TYPE: ContentType.JSON.mimeType});
|
||||
print("Login response: ${response.body}");
|
||||
expect(response.headers[HttpHeaders.LOCATION], equals('/failure'));
|
||||
expect(response.statusCode, equals(401));
|
||||
});
|
||||
|
||||
test('allow basic', () async {
|
||||
String authString = BASE64.encode("username:password".runes.toList());
|
||||
var response = await client.get("$url/hello",
|
||||
headers: {HttpHeaders.AUTHORIZATION: 'Basic $authString'});
|
||||
expect(response.body, equals('"Woo auth"'));
|
||||
});
|
||||
|
||||
test('allow basic via URL encoding', () async {
|
||||
var response = await client.get("$basicAuthUrl/hello");
|
||||
expect(response.body, equals('"Woo auth"'));
|
||||
});
|
||||
|
||||
test('force basic', () async {
|
||||
Auth.strategies.clear();
|
||||
Auth.strategies.add(new LocalAuthStrategy(verifier,
|
||||
forceBasic: true, realm: 'test'));
|
||||
var response = await client.get("$url/hello", headers: headers);
|
||||
print(response.headers);
|
||||
expect(response.headers[HttpHeaders.WWW_AUTHENTICATE],
|
||||
equals('Basic realm="test"'));
|
||||
});
|
||||
});
|
||||
}
|
111
test/local_test.dart
Normal file
111
test/local_test.dart
Normal file
|
@ -0,0 +1,111 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_auth/angel_auth.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:merge_map/merge_map.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
final AngelAuth Auth = new AngelAuth();
|
||||
Map headers = {HttpHeaders.ACCEPT: ContentType.JSON.mimeType};
|
||||
AngelAuthOptions localOpts = new AngelAuthOptions(
|
||||
failureRedirect: '/failure', successRedirect: '/success');
|
||||
Map sampleUser = {'hello': 'world'};
|
||||
|
||||
verifier(username, password) async {
|
||||
if (username == 'username' && password == 'password') {
|
||||
return sampleUser;
|
||||
} else
|
||||
return false;
|
||||
}
|
||||
|
||||
wireAuth(Angel app) async {
|
||||
Auth.serializer = (user) async => 1337;
|
||||
Auth.deserializer = (id) async => sampleUser;
|
||||
|
||||
Auth.strategies.add(new LocalAuthStrategy(verifier));
|
||||
await app.configure(Auth);
|
||||
}
|
||||
|
||||
main() async {
|
||||
Angel app;
|
||||
http.Client client;
|
||||
String url;
|
||||
String basicAuthUrl;
|
||||
|
||||
setUp(() async {
|
||||
client = new http.Client();
|
||||
app = new Angel(debug: true);
|
||||
await app.configure(wireAuth);
|
||||
app.get('/hello', 'Woo auth', middleware: [Auth.authenticate('local')]);
|
||||
app.post('/login', 'This should not be shown',
|
||||
middleware: [Auth.authenticate('local', localOpts)]);
|
||||
app.get('/success', "yep", middleware: ['auth']);
|
||||
app.get('/failure', "nope");
|
||||
|
||||
app
|
||||
..normalize()
|
||||
..dumpTree();
|
||||
|
||||
HttpServer server =
|
||||
await app.startServer(InternetAddress.LOOPBACK_IP_V4, 0);
|
||||
url = "http://${server.address.host}:${server.port}";
|
||||
basicAuthUrl =
|
||||
"http://username:password@${server.address.host}:${server.port}";
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await app.httpServer.close(force: true);
|
||||
client = null;
|
||||
url = null;
|
||||
basicAuthUrl = null;
|
||||
});
|
||||
|
||||
test('can use "auth" as middleware', () async {
|
||||
var response = await client
|
||||
.get("$url/success", headers: {'Accept': 'application/json'});
|
||||
print(response.body);
|
||||
expect(response.statusCode, equals(403));
|
||||
});
|
||||
|
||||
test('successRedirect', () async {
|
||||
Map postData = {'username': 'username', 'password': 'password'};
|
||||
var response = await client.post("$url/login",
|
||||
body: JSON.encode(postData),
|
||||
headers: {HttpHeaders.CONTENT_TYPE: ContentType.JSON.mimeType});
|
||||
expect(response.statusCode, equals(200));
|
||||
expect(response.headers[HttpHeaders.LOCATION], equals('/success'));
|
||||
});
|
||||
|
||||
test('failureRedirect', () async {
|
||||
Map postData = {'username': 'password', 'password': 'username'};
|
||||
var response = await client.post("$url/login",
|
||||
body: JSON.encode(postData),
|
||||
headers: {HttpHeaders.CONTENT_TYPE: ContentType.JSON.mimeType});
|
||||
print("Login response: ${response.body}");
|
||||
expect(response.headers[HttpHeaders.LOCATION], equals('/failure'));
|
||||
expect(response.statusCode, equals(401));
|
||||
});
|
||||
|
||||
test('allow basic', () async {
|
||||
String authString = BASE64.encode("username:password".runes.toList());
|
||||
var response = await client.get("$url/hello",
|
||||
headers: {HttpHeaders.AUTHORIZATION: 'Basic $authString'});
|
||||
expect(response.body, equals('"Woo auth"'));
|
||||
});
|
||||
|
||||
test('allow basic via URL encoding', () async {
|
||||
var response = await client.get("$basicAuthUrl/hello");
|
||||
expect(response.body, equals('"Woo auth"'));
|
||||
});
|
||||
|
||||
test('force basic', () async {
|
||||
Auth.strategies.clear();
|
||||
Auth.strategies
|
||||
.add(new LocalAuthStrategy(verifier, forceBasic: true, realm: 'test'));
|
||||
var response = await client.get("$url/hello", headers: headers);
|
||||
print(response.headers);
|
||||
expect(response.headers[HttpHeaders.WWW_AUTHENTICATE],
|
||||
equals('Basic realm="test"'));
|
||||
});
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
../packages
|
Loading…
Reference in a new issue