Skip to content

Instantly share code, notes, and snippets.

@Sov3rain
Forked from 5argon/ServiceAccountJsonToToken.cs
Last active September 4, 2024 12:05
Show Gist options
  • Save Sov3rain/f9de6a905267461f14128974294d8ab2 to your computer and use it in GitHub Desktop.
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
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