Skip to content

Commit 5397e84

Browse files
authored
Merge pull request #102 from firebase/hkj-idtoken-errors
feat(auth): Migrating ID token verification APIs to the new error handling scheme
2 parents 05108dc + c1b39a9 commit 5397e84

File tree

6 files changed

+238
-40
lines changed

6 files changed

+238
-40
lines changed

FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseTokenVerifierTest.cs

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
using System.Security.Cryptography.X509Certificates;
2121
using System.Threading;
2222
using System.Threading.Tasks;
23-
using FirebaseAdmin.Auth;
2423
using FirebaseAdmin.Tests;
2524
using Google.Apis.Auth.OAuth2;
2625
using Google.Apis.Util;
@@ -89,8 +88,10 @@ await Assert.ThrowsAsync<ArgumentException>(
8988
[Fact]
9089
public async Task MalformedToken()
9190
{
92-
await Assert.ThrowsAsync<FirebaseException>(
91+
var exception = await Assert.ThrowsAsync<FirebaseAuthException>(
9392
async () => await TokenVerifier.VerifyTokenAsync("not-a-token"));
93+
94+
this.CheckException(exception, "Incorrect number of segments in ID token.");
9495
}
9596

9697
[Fact]
@@ -101,8 +102,10 @@ public async Task NoKid()
101102
{ "kid", string.Empty },
102103
};
103104
var idToken = await CreateTestTokenAsync(headerOverrides: header);
104-
await Assert.ThrowsAsync<FirebaseException>(
105+
var exception = await Assert.ThrowsAsync<FirebaseAuthException>(
105106
async () => await TokenVerifier.VerifyTokenAsync(idToken));
107+
108+
this.CheckException(exception, "Firebase ID token has no 'kid' claim.");
106109
}
107110

108111
[Fact]
@@ -113,8 +116,10 @@ public async Task IncorrectKid()
113116
{ "kid", "incorrect-key-id" },
114117
};
115118
var idToken = await CreateTestTokenAsync(headerOverrides: header);
116-
await Assert.ThrowsAsync<FirebaseException>(
119+
var exception = await Assert.ThrowsAsync<FirebaseAuthException>(
117120
async () => await TokenVerifier.VerifyTokenAsync(idToken));
121+
122+
this.CheckException(exception, "Failed to verify ID token signature.");
118123
}
119124

120125
[Fact]
@@ -125,8 +130,12 @@ public async Task IncorrectAlgorithm()
125130
{ "alg", "HS256" },
126131
};
127132
var idToken = await CreateTestTokenAsync(headerOverrides: header);
128-
await Assert.ThrowsAsync<FirebaseException>(
133+
var exception = await Assert.ThrowsAsync<FirebaseAuthException>(
129134
async () => await TokenVerifier.VerifyTokenAsync(idToken));
135+
136+
var expectedMessage = "Firebase ID token has incorrect algorithm."
137+
+ " Expected RS256 but got HS256.";
138+
this.CheckException(exception, expectedMessage);
130139
}
131140

132141
[Fact]
@@ -138,11 +147,12 @@ public async Task Expired()
138147
{ "exp", expiryTime },
139148
};
140149
var idToken = await CreateTestTokenAsync(payloadOverrides: payload);
141-
var exception = await Assert.ThrowsAsync<FirebaseException>(
150+
var exception = await Assert.ThrowsAsync<FirebaseAuthException>(
142151
async () => await TokenVerifier.VerifyTokenAsync(idToken));
152+
143153
var expectedMessage = $"Firebase ID token expired at {expiryTime}. "
144154
+ $"Expected to be greater than {Clock.UnixTimestamp()}.";
145-
Assert.Equal(expectedMessage, exception.Message);
155+
this.CheckException(exception, expectedMessage, AuthErrorCode.ExpiredIdToken);
146156
}
147157

148158
[Fact]
@@ -170,11 +180,11 @@ public async Task InvalidIssuedAt()
170180
{ "iat", issuedAt },
171181
};
172182
var idToken = await CreateTestTokenAsync(payloadOverrides: payload);
173-
var exception = await Assert.ThrowsAsync<FirebaseException>(
183+
var exception = await Assert.ThrowsAsync<FirebaseAuthException>(
174184
async () => await TokenVerifier.VerifyTokenAsync(idToken));
175-
var expectedMessage = $"Firebase ID token issued at future timestamp {issuedAt}. "
176-
+ $"Expected to be less than {Clock.UnixTimestamp()}.";
177-
Assert.Equal(expectedMessage, exception.Message);
185+
186+
var expectedMessage = $"Firebase ID token issued at future timestamp {issuedAt}.";
187+
this.CheckException(exception, expectedMessage);
178188
}
179189

180190
[Fact]
@@ -201,8 +211,11 @@ public async Task InvalidIssuer()
201211
{ "iss", "wrong-issuer" },
202212
};
203213
var idToken = await CreateTestTokenAsync(payloadOverrides: payload);
204-
await Assert.ThrowsAsync<FirebaseException>(
214+
var exception = await Assert.ThrowsAsync<FirebaseAuthException>(
205215
async () => await TokenVerifier.VerifyTokenAsync(idToken));
216+
217+
var expectedMessage = "ID token has incorrect issuer (iss) claim.";
218+
this.CheckException(exception, expectedMessage);
206219
}
207220

208221
[Fact]
@@ -213,8 +226,12 @@ public async Task InvalidAudience()
213226
{ "aud", "wrong-audience" },
214227
};
215228
var idToken = await CreateTestTokenAsync(payloadOverrides: payload);
216-
await Assert.ThrowsAsync<FirebaseException>(
229+
var exception = await Assert.ThrowsAsync<FirebaseAuthException>(
217230
async () => await TokenVerifier.VerifyTokenAsync(idToken));
231+
232+
var expectedMessage = "ID token has incorrect audience (aud) claim."
233+
+ " Expected test-project but got wrong-audience";
234+
this.CheckException(exception, expectedMessage);
218235
}
219236

220237
[Fact]
@@ -225,8 +242,11 @@ public async Task EmptySubject()
225242
{ "sub", string.Empty },
226243
};
227244
var idToken = await CreateTestTokenAsync(payloadOverrides: payload);
228-
await Assert.ThrowsAsync<FirebaseException>(
245+
var exception = await Assert.ThrowsAsync<FirebaseAuthException>(
229246
async () => await TokenVerifier.VerifyTokenAsync(idToken));
247+
248+
var expectedMessage = "Firebase ID token has no or empty subject (sub) claim.";
249+
this.CheckException(exception, expectedMessage);
230250
}
231251

232252
[Fact]
@@ -237,8 +257,12 @@ public async Task LongSubject()
237257
{ "sub", new string('a', 129) },
238258
};
239259
var idToken = await CreateTestTokenAsync(payloadOverrides: payload);
240-
await Assert.ThrowsAsync<FirebaseException>(
260+
var exception = await Assert.ThrowsAsync<FirebaseAuthException>(
241261
async () => await TokenVerifier.VerifyTokenAsync(idToken));
262+
263+
var expectedMessage = "Firebase ID token has a subject claim longer than"
264+
+ " 128 characters.";
265+
this.CheckException(exception, expectedMessage);
242266
}
243267

244268
[Fact]
@@ -336,6 +360,18 @@ private static ISigner CreateTestSigner()
336360
var serviceAccount = (ServiceAccountCredential)credential.UnderlyingCredential;
337361
return new ServiceAccountSigner(serviceAccount);
338362
}
363+
364+
private void CheckException(
365+
FirebaseAuthException exception,
366+
string prefix,
367+
AuthErrorCode errorCode = AuthErrorCode.InvalidIdToken)
368+
{
369+
Assert.Equal(ErrorCode.InvalidArgument, exception.ErrorCode);
370+
Assert.StartsWith(prefix, exception.Message);
371+
Assert.Equal(errorCode, exception.AuthErrorCode);
372+
Assert.Null(exception.InnerException);
373+
Assert.Null(exception.HttpResponse);
374+
}
339375
}
340376

341377
internal class FileSystemPublicKeySource : IPublicKeySource

FirebaseAdmin/FirebaseAdmin.Tests/Auth/HttpPublicKeySourceTest.cs

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
using System.Net.Http;
1919
using System.Net.Http.Headers;
2020
using System.Threading.Tasks;
21-
using FirebaseAdmin.Auth;
2221
using FirebaseAdmin.Tests;
2322
using Xunit;
2423

@@ -111,8 +110,93 @@ public async Task HttpError()
111110
var clientFactory = new MockHttpClientFactory(handler);
112111
var keyManager = new HttpPublicKeySource(
113112
"https://example.com/certs", clock, clientFactory);
114-
await Assert.ThrowsAsync<FirebaseException>(
113+
114+
var exception = await Assert.ThrowsAsync<FirebaseAuthException>(
115+
async () => await keyManager.GetPublicKeysAsync());
116+
117+
Assert.Equal(ErrorCode.Internal, exception.ErrorCode);
118+
Assert.Equal(
119+
"Unexpected HTTP response with status: 500 (InternalServerError)\ntest error",
120+
exception.Message);
121+
Assert.Equal(AuthErrorCode.CertificateFetchFailed, exception.AuthErrorCode);
122+
Assert.NotNull(exception.HttpResponse);
123+
Assert.Null(exception.InnerException);
124+
125+
Assert.Equal(1, handler.Calls);
126+
}
127+
128+
[Fact]
129+
public async Task NetworkError()
130+
{
131+
var clock = new MockClock();
132+
var handler = new MockMessageHandler()
133+
{
134+
Exception = new HttpRequestException("Network error"),
135+
};
136+
var clientFactory = new MockHttpClientFactory(handler);
137+
var keyManager = new HttpPublicKeySource(
138+
"https://example.com/certs", clock, clientFactory);
139+
140+
var exception = await Assert.ThrowsAsync<FirebaseAuthException>(
115141
async () => await keyManager.GetPublicKeysAsync());
142+
143+
Assert.Equal(ErrorCode.Unknown, exception.ErrorCode);
144+
Assert.Equal(
145+
"Failed to retrieve latest public keys. Unknown error while making a remote "
146+
+ "service call: Network error",
147+
exception.Message);
148+
Assert.Equal(AuthErrorCode.CertificateFetchFailed, exception.AuthErrorCode);
149+
Assert.Null(exception.HttpResponse);
150+
Assert.Same(handler.Exception, exception.InnerException);
151+
152+
Assert.Equal(1, handler.Calls);
153+
}
154+
155+
[Fact]
156+
public async Task EmptyResponse()
157+
{
158+
var clock = new MockClock();
159+
var handler = new MockMessageHandler()
160+
{
161+
Response = "{}",
162+
};
163+
var clientFactory = new MockHttpClientFactory(handler);
164+
var keyManager = new HttpPublicKeySource(
165+
"https://example.com/certs", clock, clientFactory);
166+
167+
var exception = await Assert.ThrowsAsync<FirebaseAuthException>(
168+
async () => await keyManager.GetPublicKeysAsync());
169+
170+
Assert.Equal(ErrorCode.Unknown, exception.ErrorCode);
171+
Assert.Equal("No public keys present in the response.", exception.Message);
172+
Assert.Equal(AuthErrorCode.CertificateFetchFailed, exception.AuthErrorCode);
173+
Assert.NotNull(exception.HttpResponse);
174+
Assert.Null(exception.InnerException);
175+
176+
Assert.Equal(1, handler.Calls);
177+
}
178+
179+
[Fact]
180+
public async Task MalformedResponse()
181+
{
182+
var clock = new MockClock();
183+
var handler = new MockMessageHandler()
184+
{
185+
Response = "not json",
186+
};
187+
var clientFactory = new MockHttpClientFactory(handler);
188+
var keyManager = new HttpPublicKeySource(
189+
"https://example.com/certs", clock, clientFactory);
190+
191+
var exception = await Assert.ThrowsAsync<FirebaseAuthException>(
192+
async () => await keyManager.GetPublicKeysAsync());
193+
194+
Assert.Equal(ErrorCode.Unknown, exception.ErrorCode);
195+
Assert.Equal("Failed to parse certificate response: not json.", exception.Message);
196+
Assert.Equal(AuthErrorCode.CertificateFetchFailed, exception.AuthErrorCode);
197+
Assert.NotNull(exception.HttpResponse);
198+
Assert.NotNull(exception.InnerException);
199+
116200
Assert.Equal(1, handler.Calls);
117201
}
118202
}

FirebaseAdmin/FirebaseAdmin/Auth/AuthErrorCode.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,26 @@ namespace FirebaseAdmin.Auth
1919
/// </summary>
2020
public enum AuthErrorCode
2121
{
22+
/// <summary>
23+
/// Failed to retrieve required public key certificates.
24+
/// </summary>
25+
CertificateFetchFailed,
26+
2227
/// <summary>
2328
/// The user with the provided email already exists.
2429
/// </summary>
2530
EmailAlreadyExists,
2631

32+
/// <summary>
33+
/// The specified ID token is expired.
34+
/// </summary>
35+
ExpiredIdToken,
36+
37+
/// <summary>
38+
/// The specified ID token is invalid.
39+
/// </summary>
40+
InvalidIdToken,
41+
2742
/// <summary>
2843
/// The user with the provided phone number already exists.
2944
/// </summary>

FirebaseAdmin/FirebaseAdmin/Auth/FirebaseAuth.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ public async Task<string> CreateCustomTokenAsync(
228228
/// <returns>A task that completes with a <see cref="FirebaseToken"/> representing
229229
/// the verified and decoded ID token.</returns>
230230
/// <exception cref="ArgumentException">If ID token argument is null or empty.</exception>
231-
/// <exception cref="FirebaseException">If the ID token fails to verify.</exception>
231+
/// <exception cref="FirebaseAuthException">If the ID token fails to verify.</exception>
232232
/// <param name="idToken">A Firebase ID token string to parse and verify.</param>
233233
public async Task<FirebaseToken> VerifyIdTokenAsync(string idToken)
234234
{
@@ -249,7 +249,7 @@ public async Task<FirebaseToken> VerifyIdTokenAsync(string idToken)
249249
/// <returns>A task that completes with a <see cref="FirebaseToken"/> representing
250250
/// the verified and decoded ID token.</returns>
251251
/// <exception cref="ArgumentException">If ID token argument is null or empty.</exception>
252-
/// <exception cref="FirebaseException">If the ID token fails to verify.</exception>
252+
/// <exception cref="FirebaseAuthException">If the ID token fails to verify.</exception>
253253
/// <param name="idToken">A Firebase ID token string to parse and verify.</param>
254254
/// <param name="cancellationToken">A cancellation token to monitor the asynchronous
255255
/// operation.</param>

FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenVerifier.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ internal async Task<FirebaseToken> VerifyTokenAsync(
111111
string[] segments = token.Split('.');
112112
if (segments.Length != 3)
113113
{
114-
throw new FirebaseException($"Incorrect number of segments in ${this.shortName}.");
114+
throw this.CreateException($"Incorrect number of segments in {this.shortName}.");
115115
}
116116

117117
var header = JwtUtils.Decode<JsonWebSignature.Header>(segments[0]);
@@ -122,6 +122,7 @@ internal async Task<FirebaseToken> VerifyTokenAsync(
122122
+ $"{this.shortName}.";
123123
var issuer = this.issuer + this.ProjectId;
124124
string error = null;
125+
var errorCode = AuthErrorCode.InvalidIdToken;
125126
var currentTimeInSeconds = this.clock.UnixTimestamp();
126127

127128
if (string.IsNullOrEmpty(header.KeyId))
@@ -166,6 +167,7 @@ internal async Task<FirebaseToken> VerifyTokenAsync(
166167
{
167168
error = $"Firebase {this.shortName} expired at {payload.ExpirationTimeSeconds}. "
168169
+ $"Expected to be greater than {currentTimeInSeconds}.";
170+
errorCode = AuthErrorCode.ExpiredIdToken;
169171
}
170172
else if (string.IsNullOrEmpty(payload.Subject))
171173
{
@@ -178,7 +180,7 @@ internal async Task<FirebaseToken> VerifyTokenAsync(
178180

179181
if (error != null)
180182
{
181-
throw new FirebaseException(error);
183+
throw this.CreateException(error, errorCode);
182184
}
183185

184186
await this.VerifySignatureAsync(segments, header.KeyId, cancellationToken)
@@ -233,8 +235,14 @@ private async Task VerifySignatureAsync(
233235
);
234236
if (!verified)
235237
{
236-
throw new FirebaseException($"Failed to verify {this.shortName} signature.");
238+
throw this.CreateException($"Failed to verify {this.shortName} signature.");
237239
}
238240
}
241+
242+
private FirebaseAuthException CreateException(
243+
string message, AuthErrorCode errorCode = AuthErrorCode.InvalidIdToken)
244+
{
245+
return new FirebaseAuthException(ErrorCode.InvalidArgument, message, errorCode);
246+
}
239247
}
240248
}

0 commit comments

Comments
 (0)