Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Enable auth bearer and samples #35

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 41 additions & 41 deletions Oras.Tests/RemoteTest/RepositoryTest.cs

Large diffs are not rendered by default.

70 changes: 42 additions & 28 deletions Oras.sln
Original file line number Diff line number Diff line change
@@ -1,28 +1,42 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Oras.Tests", "Oras.Tests\Oras.Tests.csproj", "{70DFCE13-53AA-4CC2-8E1A-F73A1344A0CB}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Oras", "Oras\Oras.csproj", "{547F152F-6F4D-4E5A-AD3A-C3BC5CDCFD27}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{70DFCE13-53AA-4CC2-8E1A-F73A1344A0CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{70DFCE13-53AA-4CC2-8E1A-F73A1344A0CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{70DFCE13-53AA-4CC2-8E1A-F73A1344A0CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{70DFCE13-53AA-4CC2-8E1A-F73A1344A0CB}.Release|Any CPU.Build.0 = Release|Any CPU
{547F152F-6F4D-4E5A-AD3A-C3BC5CDCFD27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{547F152F-6F4D-4E5A-AD3A-C3BC5CDCFD27}.Debug|Any CPU.Build.0 = Debug|Any CPU
{547F152F-6F4D-4E5A-AD3A-C3BC5CDCFD27}.Release|Any CPU.ActiveCfg = Release|Any CPU
{547F152F-6F4D-4E5A-AD3A-C3BC5CDCFD27}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Oras.Tests", "Oras.Tests\Oras.Tests.csproj", "{70DFCE13-53AA-4CC2-8E1A-F73A1344A0CB}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Oras", "Oras\Oras.csproj", "{547F152F-6F4D-4E5A-AD3A-C3BC5CDCFD27}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{D198F8D2-3EBC-4ADE-A794-FF64800AAC25}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ManifestFetch", "samples\ManifestFetch\ManifestFetch.csproj", "{B82210C7-222B-4632-AE38-7BAEA6185654}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{70DFCE13-53AA-4CC2-8E1A-F73A1344A0CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{70DFCE13-53AA-4CC2-8E1A-F73A1344A0CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{70DFCE13-53AA-4CC2-8E1A-F73A1344A0CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{70DFCE13-53AA-4CC2-8E1A-F73A1344A0CB}.Release|Any CPU.Build.0 = Release|Any CPU
{547F152F-6F4D-4E5A-AD3A-C3BC5CDCFD27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{547F152F-6F4D-4E5A-AD3A-C3BC5CDCFD27}.Debug|Any CPU.Build.0 = Debug|Any CPU
{547F152F-6F4D-4E5A-AD3A-C3BC5CDCFD27}.Release|Any CPU.ActiveCfg = Release|Any CPU
{547F152F-6F4D-4E5A-AD3A-C3BC5CDCFD27}.Release|Any CPU.Build.0 = Release|Any CPU
{B82210C7-222B-4632-AE38-7BAEA6185654}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B82210C7-222B-4632-AE38-7BAEA6185654}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B82210C7-222B-4632-AE38-7BAEA6185654}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B82210C7-222B-4632-AE38-7BAEA6185654}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{B82210C7-222B-4632-AE38-7BAEA6185654} = {D198F8D2-3EBC-4ADE-A794-FF64800AAC25}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {1407904E-69B8-43DD-8CE5-67ACBCAB2416}
EndGlobalSection
EndGlobal
9 changes: 8 additions & 1 deletion Oras/Content/DigestUtility.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Oras.Exceptions;
using System;
using System.Net.NetworkInformation;
using System.Security.Cryptography;
using System.Text.RegularExpressions;

Expand All @@ -12,21 +13,27 @@ internal static class DigestUtility
/// digestRegexp checks the digest.
/// </summary>
private const string digestRegexp = @"[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+";
private static readonly Regex digestRegex = new Regex(digestRegexp, RegexOptions.Compiled);

/// <summary>
/// ParseDigest verifies the digest header and throws an exception if it is invalid.
/// </summary>
/// <param name="digest"></param>
internal static string ParseDigest(string digest)
{
if (!Regex.IsMatch(digest, digestRegexp))
if (IsDigest(digest) == false)
{
throw new InvalidDigestException($"Invalid digest: {digest}");
}

return digest;
}

internal static bool IsDigest(string digest)
{
return !String.IsNullOrEmpty(digest) && digestRegex.IsMatch(digest);
}

/// <summary>
/// CalculateSHA256DigestFromBytes generates a SHA256 digest from a byte array.
/// </summary>
Expand Down
122 changes: 122 additions & 0 deletions Oras/Remote/RegistryMessageHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Net;
using System.Linq;

namespace Oras.Remote
{
internal class RegistryMessageHandler : HttpClientHandler
{

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var res = await base.SendAsync(request, cancellationToken);

// If this is unauthorized there should be a challenge header
if (res.StatusCode == HttpStatusCode.Unauthorized)
{
var token = await GetAccessTokenAsync(res, cancellationToken);
request.Headers.Add("Authorization", "Bearer " + token);
return await base.SendAsync(request, cancellationToken);
}

return res;
}

public const string AuthenticateHeaderKey = "Www-Authenticate";

/// <summary>
/// Get a docker access token for a URI using OAuth2 flow with retry.
/// </summary>
public async Task<string> GetAccessTokenAsync(
HttpResponseMessage challenge,
CancellationToken cancellationToken)
{
var uri = challenge.RequestMessage?.RequestUri;

/*
* Www-Authenticate: Bearer realm="https://auth.docker.io/token",
* service="registry.docker.io",scope="repository:library/official-app:pull"
*/
if (challenge.StatusCode != HttpStatusCode.Unauthorized
|| !challenge.Headers.Contains(AuthenticateHeaderKey))
{
throw new Exception($"URI {uri} did not issue a challenge with status code: {challenge.StatusCode}");
}

var authenticateHeaderValue = challenge.Headers.GetValues(AuthenticateHeaderKey).FirstOrDefault();

if (string.IsNullOrEmpty(authenticateHeaderValue))
{
throw new Exception($"Empty authenticate header.");
}

var authenticate = authenticateHeaderValue.Split(' ');
if (authenticate.Length != 2 || string.Compare(authenticate[0], "Bearer", true) < 0)
{
throw new Exception($"URI {uri} did not return correct authenticate header {authenticateHeaderValue}.");
}

var tokens = authenticate[1].Split(',').Select(t =>
{
return t.Trim().Split('=');
}).ToDictionary(t => t[0], t => t[1]);

if (!(tokens.ContainsKey("realm")
&& tokens.ContainsKey("service")
&& tokens.ContainsKey("scope")))
{
throw new Exception($"URI {uri} did not return authenticate header with necessary fields {authenticateHeaderValue}.");
}

var authUri = $"{tokens["realm"].Trim('"')}?service={tokens["service"].Trim('"')}&scope={tokens["scope"].Trim('"')}";

// handle retries
// create request message for authUri
var requestMsg = new HttpRequestMessage(HttpMethod.Get, authUri);
var response = await base.SendAsync(requestMsg, cancellationToken);

if (response.IsSuccessStatusCode)
{
var strToken = await response?.Content?.ReadAsStringAsync();

var oAuthToken = JsonSerializer.Deserialize<OAuthToken>(strToken);
if (string.IsNullOrEmpty(oAuthToken?.Token))
{
throw new Exception($"URI {authUri} could not return a valid access token.");
}

return oAuthToken.Token;
}
else if (response.StatusCode == HttpStatusCode.NotFound)
{
throw new Exception($"Request failed with status code: {response.StatusCode}.");
}
else
{
throw new Exception($"Request failed with status code: {response.StatusCode}.");
}
}
}

class OAuthToken
{
[JsonPropertyName("token")]
public string Token { get; set; }

[JsonPropertyName("access_token")]
public string AccessToken { get; set; }

[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }

[JsonPropertyName("issued_at")]
public string IssuedAt { get; set; }
}
}
10 changes: 10 additions & 0 deletions Oras/Remote/RemoteReference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,5 +196,15 @@ public string Digest()
return Reference;
}

/// <summary>
/// IsDigest returns if the reference part is of a Digest form
/// </summary>
/// <returns></returns>
public bool IsDigest()
{
return DigestUtility.IsDigest(Reference);
}


}
}
19 changes: 6 additions & 13 deletions Oras/Remote/Repository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -60,8 +61,8 @@ public class Repository : IRepository, IRepositoryOption
/// <param name="reference"></param>
public Repository(string reference)
{
RemoteReference = RemoteReference.ParseReference(reference);
HttpClient = new HttpClient();
RemoteReference = RemoteReference.ParseReference(reference);
HttpClient = new HttpClient(new RegistryMessageHandler());
HttpClient.DefaultRequestHeaders.Add("User-Agent", new string[] { "oras-dotnet" });
}

Expand Down Expand Up @@ -340,7 +341,7 @@ internal async Task DeleteAsync(Descriptor target, bool isManifest, Cancellation
/// <exception cref="NotImplementedException"></exception>
internal static void VerifyContentDigest(HttpResponseMessage resp, string expected)
{
if (!resp.Content.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)) return;
if (!resp.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)) return;
var digestStr = digestValues.FirstOrDefault();
if (string.IsNullOrEmpty(digestStr))
{
Expand Down Expand Up @@ -628,18 +629,10 @@ public async Task<Descriptor> GenerateDescriptor(HttpResponseMessage res, Remote
}

// 3. Validate Client Reference
string refDigest = string.Empty;
try
{
refDigest = reference.Digest();
}
catch (Exception)
{
}

string refDigest = reference.IsDigest() ? reference.Digest() : string.Empty;

// 4. Validate Server Digest (if present)
res.Content.Headers.TryGetValues("Docker-Content-Digest", out IEnumerable<string> serverHeaderDigest);
res.Headers.TryGetValues("Docker-Content-Digest", out IEnumerable<string> serverHeaderDigest);
var serverDigest = serverHeaderDigest?.First();
if (!string.IsNullOrEmpty(serverDigest))
{
Expand Down
14 changes: 14 additions & 0 deletions samples/ManifestFetch/ManifestFetch.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\Oras\Oras.csproj" />
</ItemGroup>

</Project>
14 changes: 14 additions & 0 deletions samples/ManifestFetch/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
var reference = "ghcr.io/oras-project/oras:v1.1.0";
var repo = new Oras.Remote.Repository(reference);

// Get the content digest
var desc = await repo.ResolveAsync(reference);
Console.WriteLine("Digest: {0}", desc.Digest);

// Retrive the manifest content
var content = await repo.Manifests().FetchReferenceAsync(reference);
using (var reader = new StreamReader(content.Stream))
{
var output = await reader.ReadToEndAsync();
Console.WriteLine(output);
}