Skip to content

Commit c11465b

Browse files
authored
Merge pull request #201 from guardian/an/allow-concurrent-auths
attempts to fix the concurrent authentication problem by namespacing …
2 parents 64fcc57 + 5d398d9 commit c11465b

File tree

2 files changed

+41
-22
lines changed

2 files changed

+41
-22
lines changed

pan-domain-auth-play/src/main/scala/com/gu/pandomainauth/action/Actions.scala

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,16 @@ trait AuthActions {
8080
new Google2FAGroupChecker(_, panDomainSettings.s3BucketLoader, applicationName)
8181
}
8282

83-
/**
84-
* A cookie key that stores the target URL that was being accessed when redirected for authentication
85-
*/
86-
val LOGIN_ORIGIN_KEY = "panda-loginOriginUrl"
83+
/*
84+
* A cookie key that stores the target URL that was being accessed when redirected for authentication.
85+
* Takes a session ID to allow multiple tabs to reauth concurrently without conflict.
86+
*/
87+
def loginOriginKey(sessionId: String) = s"panda-loginOriginUrl-$sessionId"
8788
/*
8889
* Cookie key containing an anti-forgery token; helps to validate that the oauth callback arrived in response to the correct oauth request
90+
* Takes a session ID to allow multiple tabs to reauth concurrently without conflict.
8991
*/
90-
val ANTI_FORGERY_KEY = "panda-antiForgeryToken"
92+
def antiForgeryTokenKey(sessionId: String) = s"panda-antiForgeryToken-$sessionId"
9193
/*
9294
* Cookie that will make panda behave as if the cookie has expired.
9395
* NOTE: This cookie is for debugging only! It should _not_ be set by any application code to expire the cookie!! Use the `processLogout` action instead!!
@@ -98,16 +100,17 @@ trait AuthActions {
98100
Cookie(
99101
name,
100102
value = URLEncoder.encode(value, "UTF-8"),
103+
maxAge = Some(300), // 5 mins - plenty of time for auth to complete
101104
secure = true,
102105
httpOnly = true,
103106
// Chrome will pass back SameSite=Lax cookies, but Firefox requires
104107
// SameSite=None, since the cookies are to be returned on a redirect
105108
// from a 3rd party
106109
sameSite = Some(Cookie.SameSite.None)
107110
)
108-
private lazy val discardCookies = Seq(
109-
DiscardingCookie(LOGIN_ORIGIN_KEY, secure = true),
110-
DiscardingCookie(ANTI_FORGERY_KEY, secure = true),
111+
private def discardCookies(sessionId: String) = Seq(
112+
DiscardingCookie(loginOriginKey(sessionId), secure = true),
113+
DiscardingCookie(antiForgeryTokenKey(sessionId), secure = true),
111114
DiscardingCookie(FORCE_EXPIRY_KEY, secure = true)
112115
)
113116

@@ -116,10 +119,14 @@ trait AuthActions {
116119
* but if you want to show welcome page with a button on it then override.
117120
*/
118121
def sendForAuth(implicit request: RequestHeader, email: Option[String] = None) = {
122+
val sessionId = OAuth.generateSessionId()
119123
val antiForgeryToken = OAuth.generateAntiForgeryToken()
120-
OAuth.redirectToOAuthProvider(antiForgeryToken, email)(ec) map { res =>
124+
OAuth.redirectToOAuthProvider(sessionId, antiForgeryToken, email)(ec) map { res =>
121125
val originUrl = request.uri
122-
res.withCookies(cookie(ANTI_FORGERY_KEY, antiForgeryToken), cookie(LOGIN_ORIGIN_KEY, originUrl))
126+
res.withCookies(
127+
cookie(antiForgeryTokenKey(sessionId), antiForgeryToken),
128+
cookie(loginOriginKey(sessionId), originUrl)
129+
).discardingHeader(FORCE_EXPIRY_KEY)
123130
}
124131
}
125132

@@ -150,14 +157,24 @@ trait AuthActions {
150157
def invalidUserMessage(claimedAuth: AuthenticatedUser) = s"user ${claimedAuth.user.email} not valid for $system"
151158

152159
private def decodeCookie(name: String)(implicit request: RequestHeader) =
153-
request.cookies.get(name).map(cookie => URLDecoder.decode(cookie.value, "UTF-8"))
160+
request.cookies.get(name).map(cookie => URLDecoder.decode(cookie.value, "UTF-8")).toRight(
161+
left = BadRequest(s"missing cookie $name")
162+
)
163+
164+
private def fetchSessionIdFromState()(implicit request: RequestHeader) =
165+
request.getQueryString("state") match {
166+
case Some(s"$sessionId+$_") => Right(sessionId)
167+
case Some(_) => Left(BadRequest("State parameter returned missing a session ID"))
168+
case None => Left(BadRequest("No state parameter passed in callback"))
169+
}
154170

155171
def processOAuthCallback()(implicit request: RequestHeader): Future[Result] = {
156172
(for {
157-
token <- decodeCookie(ANTI_FORGERY_KEY)
158-
originalUrl <- decodeCookie(LOGIN_ORIGIN_KEY)
173+
sessionId <- fetchSessionIdFromState()
174+
token <- decodeCookie(antiForgeryTokenKey(sessionId))
175+
originalUrl <- decodeCookie(loginOriginKey(sessionId))
159176
} yield {
160-
OAuth.validatedUserIdentity(token)(request, ec, wsClient).map { claimedAuth =>
177+
OAuth.validatedUserIdentity(sessionId, token)(request, ec, wsClient).map { claimedAuth =>
161178
val existingAuthenticatedIn = readAuthenticatedUser(request).map(_.authenticatedIn)
162179
val authedUserData =
163180
claimedAuth.copy(
@@ -170,13 +187,14 @@ trait AuthActions {
170187
val updatedCookie = generateCookie(authedUserData)
171188
Redirect(originalUrl)
172189
.withCookies(updatedCookie)
173-
.discardingCookies(discardCookies:_*)
190+
.discardingCookies(discardCookies(sessionId): _*)
174191
} else {
175192
showUnauthedMessage(invalidUserMessage(authedUserData))
176193
}
177194
}
178-
}) getOrElse {
179-
Future.successful(BadRequest("Missing cookies"))
195+
}) match {
196+
case Left(failure) => Future.successful(failure)
197+
case Right(eventualSuccess) => eventualSuccess
180198
}
181199
}
182200

pan-domain-auth-play/src/main/scala/com/gu/pandomainauth/service/OAuth.scala

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ class OAuth(config: OAuthSettings, system: String, redirectUrl: String)(implicit
3535

3636
val random = new SecureRandom()
3737

38-
def generateAntiForgeryToken() = new BigInteger(130, random).toString(32)
38+
def generateSessionId(): String = Integer.toString(random.nextInt(Integer.MAX_VALUE), 36)
39+
def generateAntiForgeryToken(): String = new BigInteger(130, random).toString(36)
3940

4041
def oAuthResponse[T](r: WSResponse)(block: JsValue => T): T = {
4142
r.status match {
@@ -51,22 +52,22 @@ class OAuth(config: OAuthSettings, system: String, redirectUrl: String)(implicit
5152
}
5253
}
5354

54-
def redirectToOAuthProvider(antiForgeryToken: String, email: Option[String] = None)
55+
def redirectToOAuthProvider(sessionId: String, antiForgeryToken: String, email: Option[String] = None)
5556
(implicit context: ExecutionContext): Future[Result] = {
5657
val queryString: Map[String, Seq[String]] = Map(
5758
"client_id" -> Seq(config.clientId),
5859
"response_type" -> Seq("code"),
5960
"scope" -> Seq("openid email profile"),
6061
"redirect_uri" -> Seq(redirectUrl),
61-
"state" -> Seq(antiForgeryToken)
62+
"state" -> Seq(s"$sessionId+$antiForgeryToken")
6263
) ++ email.map("login_hint" -> Seq(_)) ++ config.organizationDomain.map("hd" -> Seq(_))
6364

6465
discoveryDocument.map(dd => Redirect(s"${dd.authorization_endpoint}", queryString))
6566
}
6667

67-
def validatedUserIdentity(expectedAntiForgeryToken: String)
68+
def validatedUserIdentity(sessionId: String, expectedAntiForgeryToken: String)
6869
(implicit request: RequestHeader, context: ExecutionContext, ws: WSClient): Future[AuthenticatedUser] = {
69-
if (!request.queryString.getOrElse("state", Nil).contains(expectedAntiForgeryToken)) {
70+
if (!request.getQueryString("state").contains(s"$sessionId+$expectedAntiForgeryToken")) {
7071
throw new IllegalArgumentException("The anti forgery token did not match")
7172
} else {
7273
discoveryDocument.flatMap { dd =>

0 commit comments

Comments
 (0)