Forked from 5argon/ServiceAccountJsonToToken.cs
Last active
September 4, 2024 12:05
-
-
Save Sov3rain/f9de6a905267461f14128974294d8ab2 to your computer and use it in GitHub Desktop.
From service account .json file -> JWT -> OAuth2 service account token with pure REST API in Unity
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections; | |
using System.Collections.Generic; | |
using System.IO; | |
using System.Security.Cryptography; | |
using System.Text; | |
using UnityEngine; | |
using UnityEngine.Networking; | |
using static UnityEngine.Networking.UnityWebRequest.Result; | |
/* Example usage: | |
private IEnumerator MakeAuthenticatedRequest() | |
{ | |
var auth = new ServiceAccountJsonToToken(firebaseServiceAccount); | |
yield return auth.GetAccessToken(); | |
if (auth.AccessToken is null) | |
{ | |
// Handle error. | |
yield break; | |
} | |
var url = $"{databaseUrl}?access_token={auth.AccessToken}"; | |
// Create your UnityWebRequest and send it. | |
} | |
*/ | |
internal class ServiceAccountJsonToToken | |
{ | |
private const string _scope = "https://www.googleapis.com/auth/userinfo.email " + | |
"https://www.googleapis.com/auth/cloud-platform " + | |
"https://www.googleapis.com/auth/datastore"; | |
private readonly string _privateKey; | |
private readonly string[] _scopes; | |
private readonly string _serviceEmail; | |
public string AccessToken { get; private set; } | |
public ServiceAccountJsonToToken(TextAsset serviceAccountFile) | |
{ | |
try | |
{ | |
ServiceAccount serviceAccount = JsonUtility.FromJson<ServiceAccount>(serviceAccountFile.text); | |
_serviceEmail = serviceAccount.client_email; | |
_privateKey = serviceAccount.private_key; | |
} | |
catch (Exception) | |
{ | |
Debug.LogError("Invalid JSON file"); | |
throw; | |
} | |
} | |
/// <summary> | |
/// PITA JWT [Kungfu](https://developers.google.com/identity/protocols/OAuth2ServiceAccount) | |
/// </summary> | |
public IEnumerator GetAccessToken(long expiresInSecond = 5) | |
{ | |
string jwtHeader = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"; | |
var time = DateTimeOffset.Now.ToUnixTimeSeconds(); | |
JwtObject jwtObject = new() | |
{ | |
iss = _serviceEmail, | |
scope = _scope, | |
aud = "https://www.googleapis.com/oauth2/v4/token", | |
exp = time + expiresInSecond, | |
iat = time, | |
}; | |
string jwtJson = JsonUtility.ToJson(jwtObject); | |
string jwtClaimSet = Convert.ToBase64String(Encoding.UTF8.GetBytes(jwtJson)); | |
RSAParameters rsaParameters = DecodeRsaParameters(_privateKey); | |
RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(); | |
rsa.ImportParameters(rsaParameters); | |
byte[] signatureBytes = rsa.SignData(Encoding.UTF8.GetBytes($"{jwtHeader}.{jwtClaimSet}"), "SHA256"); | |
string jwtSignature = Convert.ToBase64String(signatureBytes); | |
string completeJwt = $"{jwtHeader}.{jwtClaimSet}.{jwtSignature}"; | |
var req = UnityWebRequest.Post("https://www.googleapis.com/oauth2/v4/token", new Dictionary<string, string> | |
{ | |
["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer", | |
["assertion"] = completeJwt | |
}); | |
req.SetRequestHeader("Content-Type", "application/x-www-form-urlencoded"); | |
yield return req.SendWebRequest(); | |
var res = JsonUtility.FromJson<JwtResponse>(req.downloadHandler.text); | |
if (req.result is not Success) | |
{ | |
Debug.LogError($"Getting service account access token error! {req.error} {req.downloadHandler.text}"); | |
yield break; | |
} | |
AccessToken = res.access_token; | |
} | |
private struct ServiceAccount | |
{ | |
public string type; | |
public string project_id; | |
public string private_key_id; | |
public string private_key; | |
public string client_email; | |
public string client_id; | |
public string auth_uri; | |
public string token_uri; | |
public string auth_provider_x509_cert_url; | |
public string client_x509_cert_url; | |
public string universe_domain; | |
} | |
private struct JwtResponse | |
{ | |
public string access_token; | |
public int expires_in; | |
public string token_type; | |
} | |
private struct JwtObject | |
{ | |
public string iss; | |
public string scope; | |
public string aud; | |
public long exp; | |
public long iat; | |
} | |
// This part on was from : https://github.com/googleapis/google-api-dotnet-client/blob/master/Src/Support/Google.Apis.Auth/OAuth2/Pkcs8.cs | |
// PKCS#8 specification: https://www.ietf.org/rfc/rfc5208.txt | |
// ASN.1 specification: https://www.itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf | |
/// <summary> | |
/// An incomplete ASN.1 decoder, only implements what's required | |
/// to decode a Service Credential. | |
/// </summary> | |
private class Asn1 | |
{ | |
private enum Tag | |
{ | |
Integer = 2, | |
OctetString = 4, | |
Null = 5, | |
ObjectIdentifier = 6, | |
Sequence = 16, | |
} | |
private class Decoder | |
{ | |
public Decoder(byte[] bytes) | |
{ | |
_bytes = bytes; | |
_index = 0; | |
} | |
private byte[] _bytes; | |
private int _index; | |
public object Decode() | |
{ | |
Tag tag = ReadTag(); | |
switch (tag) | |
{ | |
case Tag.Integer: | |
return ReadInteger(); | |
case Tag.OctetString: | |
return ReadOctetString(); | |
case Tag.Null: | |
return ReadNull(); | |
case Tag.ObjectIdentifier: | |
return ReadOid(); | |
case Tag.Sequence: | |
return ReadSequence(); | |
default: | |
throw new NotSupportedException($"Tag '{tag}' not supported."); | |
} | |
} | |
private byte NextByte() => _bytes[_index++]; | |
private byte[] ReadLengthPrefixedBytes() | |
{ | |
int length = ReadLength(); | |
return ReadBytes(length); | |
} | |
private byte[] ReadInteger() => ReadLengthPrefixedBytes(); | |
private object ReadOctetString() | |
{ | |
byte[] bytes = ReadLengthPrefixedBytes(); | |
return new Decoder(bytes).Decode(); | |
} | |
private object ReadNull() | |
{ | |
int length = ReadLength(); | |
if (length != 0) | |
{ | |
throw new InvalidDataException("Invalid data, Null length must be 0."); | |
} | |
return null; | |
} | |
private int[] ReadOid() | |
{ | |
byte[] oidBytes = ReadLengthPrefixedBytes(); | |
List<int> result = new List<int>(); | |
bool first = true; | |
int index = 0; | |
while (index < oidBytes.Length) | |
{ | |
int subId = 0; | |
byte b; | |
do | |
{ | |
b = oidBytes[index++]; | |
if ((subId & 0xff000000) != 0) | |
{ | |
throw new NotSupportedException("Oid subId > 2^31 not supported."); | |
} | |
subId = (subId << 7) | (b & 0x7f); | |
} while ((b & 0x80) != 0); | |
if (first) | |
{ | |
first = false; | |
result.Add(subId / 40); | |
result.Add(subId % 40); | |
} | |
else | |
{ | |
result.Add(subId); | |
} | |
} | |
return result.ToArray(); | |
} | |
private object[] ReadSequence() | |
{ | |
int length = ReadLength(); | |
int endOffset = _index + length; | |
if (endOffset < 0 || endOffset > _bytes.Length) | |
{ | |
throw new InvalidDataException("Invalid sequence, too long."); | |
} | |
List<object> sequence = new List<object>(); | |
while (_index < endOffset) | |
{ | |
sequence.Add(Decode()); | |
} | |
return sequence.ToArray(); | |
} | |
private byte[] ReadBytes(int length) | |
{ | |
if (length <= 0) | |
{ | |
throw new ArgumentOutOfRangeException(nameof(length), "length must be positive."); | |
} | |
if (_bytes.Length - length < 0) | |
{ | |
throw new ArgumentException("Cannot read past end of buffer."); | |
} | |
byte[] result = new byte[length]; | |
Array.Copy(_bytes, _index, result, 0, length); | |
_index += length; | |
return result; | |
} | |
private Tag ReadTag() | |
{ | |
byte b = NextByte(); | |
int tag = b & 0x1f; | |
if (tag == 0x1f) | |
{ | |
// A tag value of 0x1f (31) indicates a tag value of >30 (spec section 8.1.2.4) | |
throw new NotSupportedException("Tags of value > 30 not supported."); | |
} | |
return (Tag)tag; | |
} | |
private int ReadLength() | |
{ | |
byte b0 = NextByte(); | |
if ((b0 & 0x80) == 0) | |
{ | |
return b0; | |
} | |
if (b0 == 0xff) | |
{ | |
throw new InvalidDataException("Invalid length byte: 0xff"); | |
} | |
int byteCount = b0 & 0x7f; | |
if (byteCount == 0) | |
{ | |
throw new NotSupportedException("Lengths in Indefinite Form not supported."); | |
} | |
int result = 0; | |
for (int i = 0; i < byteCount; i++) | |
{ | |
if ((result & 0xff800000) != 0) | |
{ | |
throw new NotSupportedException("Lengths > 2^31 not supported."); | |
} | |
result = (result << 8) | NextByte(); | |
} | |
return result; | |
} | |
} | |
public static object Decode(byte[] bs) => new Decoder(bs).Decode(); | |
} | |
private static RSAParameters DecodeRsaParameters(string pkcs8PrivateKey) | |
{ | |
const string PrivateKeyPrefix = "-----BEGIN PRIVATE KEY-----"; | |
const string PrivateKeySuffix = "-----END PRIVATE KEY-----"; | |
pkcs8PrivateKey = pkcs8PrivateKey.Trim(); | |
if (!pkcs8PrivateKey.StartsWith(PrivateKeyPrefix) || !pkcs8PrivateKey.EndsWith(PrivateKeySuffix)) | |
{ | |
throw new ArgumentException( | |
$"PKCS8 data must be contained within '{PrivateKeyPrefix}' and '{PrivateKeySuffix}'.", | |
nameof(pkcs8PrivateKey)); | |
} | |
string base64PrivateKey = pkcs8PrivateKey | |
.Substring( | |
PrivateKeyPrefix.Length, | |
pkcs8PrivateKey.Length - PrivateKeyPrefix.Length - PrivateKeySuffix.Length) | |
.Replace("\\n", ""); | |
// FromBase64String() ignores whitespace, so further Trim()ing isn't required. | |
byte[] pkcs8Bytes = Convert.FromBase64String(base64PrivateKey); | |
object ans1 = Asn1.Decode(pkcs8Bytes); | |
object[] parameters = (object[])((object[])ans1)[2]; | |
var rsaParameters = new RSAParameters | |
{ | |
Modulus = TrimLeadingZeroes((byte[])parameters[1]), | |
Exponent = TrimLeadingZeroes((byte[])parameters[2], alignTo8Bytes: false), | |
D = TrimLeadingZeroes((byte[])parameters[3]), | |
P = TrimLeadingZeroes((byte[])parameters[4]), | |
Q = TrimLeadingZeroes((byte[])parameters[5]), | |
DP = TrimLeadingZeroes((byte[])parameters[6]), | |
DQ = TrimLeadingZeroes((byte[])parameters[7]), | |
InverseQ = TrimLeadingZeroes((byte[])parameters[8]), | |
}; | |
return rsaParameters; | |
} | |
private static byte[] TrimLeadingZeroes(byte[] bs, bool alignTo8Bytes = true) | |
{ | |
int zeroCount = 0; | |
while (zeroCount < bs.Length && bs[zeroCount] == 0) zeroCount += 1; | |
int newLength = bs.Length - zeroCount; | |
if (alignTo8Bytes) | |
{ | |
int remainder = newLength & 0x07; | |
if (remainder != 0) | |
{ | |
newLength += 8 - remainder; | |
} | |
} | |
if (newLength == bs.Length) | |
{ | |
return bs; | |
} | |
byte[] result = new byte[newLength]; | |
if (newLength < bs.Length) | |
{ | |
Buffer.BlockCopy(bs, bs.Length - newLength, result, 0, newLength); | |
} | |
else | |
{ | |
Buffer.BlockCopy(bs, 0, result, newLength - bs.Length, bs.Length); | |
} | |
return result; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment