mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 08:44:14 +00:00
type-ahead login api. fix app-passwords not working without oauth
This commit is contained in:
@@ -348,8 +348,12 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
app := handlers.NewApp(ctx, cfg.Distribution)
|
||||
|
||||
// Wrap registry app with auth method extraction middleware
|
||||
// This extracts the auth method from the JWT and stores it in the request context
|
||||
wrappedApp := middleware.ExtractAuthMethod(app)
|
||||
|
||||
// Mount registry at /v2/
|
||||
mainRouter.Handle("/v2/*", app)
|
||||
mainRouter.Handle("/v2/*", wrappedApp)
|
||||
|
||||
// Mount static files if UI is enabled
|
||||
if uiSessionStore != nil && uiTemplates != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/distribution/distribution/v3"
|
||||
@@ -23,6 +24,9 @@ import (
|
||||
// holdDIDKey is the context key for storing hold DID
|
||||
const holdDIDKey contextKey = "hold.did"
|
||||
|
||||
// authMethodKey is the context key for storing auth method from JWT
|
||||
const authMethodKey contextKey = "auth.method"
|
||||
|
||||
// Global variables for initialization only
|
||||
// These are set by main.go during startup and copied into NamespaceResolver instances.
|
||||
// After initialization, request handling uses the NamespaceResolver's instance fields.
|
||||
@@ -162,12 +166,45 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
|
||||
}
|
||||
|
||||
// Get service token for hold authentication
|
||||
// Route based on auth method from JWT token
|
||||
var serviceToken string
|
||||
if nr.refresher != nil {
|
||||
authMethod, _ := ctx.Value(authMethodKey).(string)
|
||||
|
||||
if authMethod == token.AuthMethodAppPassword {
|
||||
// App-password flow: use Bearer token authentication
|
||||
slog.Debug("Using app-password flow for service token",
|
||||
"component", "registry/middleware",
|
||||
"did", did)
|
||||
|
||||
var err error
|
||||
serviceToken, err = token.GetOrFetchServiceTokenWithAppPassword(ctx, did, holdDID, pdsEndpoint)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get service token with app-password",
|
||||
"component", "registry/middleware",
|
||||
"did", did,
|
||||
"holdDID", holdDID,
|
||||
"pdsEndpoint", pdsEndpoint,
|
||||
"error", err)
|
||||
|
||||
// Check if app-password is expired/invalid
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "expired or invalid") || strings.Contains(errMsg, "no app-password") {
|
||||
return nil, nr.authErrorMessage("App-password authentication failed. Please re-authenticate with: docker login")
|
||||
}
|
||||
|
||||
// Generic service token error
|
||||
return nil, nr.authErrorMessage(fmt.Sprintf("Failed to obtain storage credentials: %v", err))
|
||||
}
|
||||
} else if nr.refresher != nil {
|
||||
// OAuth flow: use DPoP authentication
|
||||
slog.Debug("Using OAuth flow for service token",
|
||||
"component", "registry/middleware",
|
||||
"did", did)
|
||||
|
||||
var err error
|
||||
serviceToken, err = token.GetOrFetchServiceToken(ctx, nr.refresher, did, holdDID, pdsEndpoint)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get service token",
|
||||
slog.Error("Failed to get service token with OAuth",
|
||||
"component", "registry/middleware",
|
||||
"did", did,
|
||||
"holdDID", holdDID,
|
||||
@@ -234,6 +271,11 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
|
||||
// Example: "evan.jarrett.net/debian" -> store as "debian"
|
||||
repositoryName := imageName
|
||||
|
||||
// Default auth method to OAuth if not already set (backward compatibility with old tokens)
|
||||
if authMethod == "" {
|
||||
authMethod = token.AuthMethodOAuth
|
||||
}
|
||||
|
||||
// Create routing repository - routes manifests to ATProto, blobs to hold service
|
||||
// The registry is stateless - no local storage is used
|
||||
// Bundle all context into a single RegistryContext struct
|
||||
@@ -251,6 +293,7 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
|
||||
Repository: repositoryName,
|
||||
ServiceToken: serviceToken, // Cached service token from middleware validation
|
||||
ATProtoClient: atprotoClient,
|
||||
AuthMethod: authMethod, // Auth method from JWT token
|
||||
Database: nr.database,
|
||||
Authorizer: nr.authorizer,
|
||||
Refresher: nr.refresher,
|
||||
@@ -348,3 +391,32 @@ func (nr *NamespaceResolver) isHoldReachable(ctx context.Context, holdDID string
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ExtractAuthMethod is an HTTP middleware that extracts the auth method from the JWT Authorization header
|
||||
// and stores it in the request context for later use by the registry middleware
|
||||
func ExtractAuthMethod(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader != "" {
|
||||
// Parse "Bearer <token>" format
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
||||
tokenString := parts[1]
|
||||
|
||||
// Extract auth method from JWT (does not validate - just parses)
|
||||
authMethod := token.ExtractAuthMethod(tokenString)
|
||||
if authMethod != "" {
|
||||
// Store in context for registry middleware
|
||||
ctx := context.WithValue(r.Context(), authMethodKey, authMethod)
|
||||
r = r.WithContext(ctx)
|
||||
slog.Debug("Extracted auth method from JWT",
|
||||
"component", "registry/middleware",
|
||||
"authMethod", authMethod)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1083,6 +1083,98 @@ a.license-badge:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Login Typeahead */
|
||||
.login-form .form-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.typeahead-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
box-shadow: var(--shadow-md);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.typeahead-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.typeahead-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.typeahead-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.typeahead-item:hover,
|
||||
.typeahead-item.typeahead-focused {
|
||||
background: var(--hover-bg);
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: calc(0.75rem - 3px);
|
||||
}
|
||||
|
||||
.typeahead-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.typeahead-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.typeahead-displayname {
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.typeahead-handle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.typeahead-recent .typeahead-handle {
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.typeahead-loading {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
color: var(--secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Repository Page */
|
||||
.repository-page {
|
||||
/* Let container's max-width (1200px) control page width */
|
||||
|
||||
@@ -445,3 +445,283 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Login page typeahead functionality
|
||||
class LoginTypeahead {
|
||||
constructor(inputElement) {
|
||||
this.input = inputElement;
|
||||
this.dropdown = null;
|
||||
this.debounceTimer = null;
|
||||
this.currentFocus = -1;
|
||||
this.results = [];
|
||||
this.isLoading = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Create dropdown element
|
||||
this.createDropdown();
|
||||
|
||||
// Event listeners
|
||||
this.input.addEventListener('input', (e) => this.handleInput(e));
|
||||
this.input.addEventListener('keydown', (e) => this.handleKeydown(e));
|
||||
this.input.addEventListener('focus', () => this.handleFocus());
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.input.contains(e.target) && !this.dropdown.contains(e.target)) {
|
||||
this.hideDropdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createDropdown() {
|
||||
this.dropdown = document.createElement('div');
|
||||
this.dropdown.className = 'typeahead-dropdown';
|
||||
this.dropdown.style.display = 'none';
|
||||
this.input.parentNode.insertBefore(this.dropdown, this.input.nextSibling);
|
||||
}
|
||||
|
||||
handleInput(e) {
|
||||
const value = e.target.value.trim();
|
||||
|
||||
// Clear debounce timer
|
||||
clearTimeout(this.debounceTimer);
|
||||
|
||||
if (value.length < 2) {
|
||||
this.showRecentAccounts();
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce API call (200ms)
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.searchActors(value);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
handleFocus() {
|
||||
const value = this.input.value.trim();
|
||||
if (value.length < 2) {
|
||||
this.showRecentAccounts();
|
||||
}
|
||||
}
|
||||
|
||||
async searchActors(query) {
|
||||
this.isLoading = true;
|
||||
this.showLoading();
|
||||
|
||||
try {
|
||||
const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=3`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch suggestions');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.results = data.actors || [];
|
||||
this.renderResults();
|
||||
} catch (err) {
|
||||
console.error('Typeahead error:', err);
|
||||
this.hideDropdown();
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.dropdown.innerHTML = '<div class="typeahead-loading">Searching...</div>';
|
||||
this.dropdown.style.display = 'block';
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
if (this.results.length === 0) {
|
||||
this.hideDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
this.dropdown.innerHTML = '';
|
||||
this.currentFocus = -1;
|
||||
|
||||
this.results.slice(0, 3).forEach((actor, index) => {
|
||||
const item = this.createResultItem(actor, index);
|
||||
this.dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
this.dropdown.style.display = 'block';
|
||||
}
|
||||
|
||||
createResultItem(actor, index) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'typeahead-item';
|
||||
item.dataset.index = index;
|
||||
item.dataset.handle = actor.handle;
|
||||
|
||||
// Avatar
|
||||
const avatar = document.createElement('img');
|
||||
avatar.className = 'typeahead-avatar';
|
||||
avatar.src = actor.avatar || '/static/images/default-avatar.png';
|
||||
avatar.alt = actor.handle;
|
||||
avatar.onerror = () => {
|
||||
avatar.src = '/static/images/default-avatar.png';
|
||||
};
|
||||
|
||||
// Text container
|
||||
const textContainer = document.createElement('div');
|
||||
textContainer.className = 'typeahead-text';
|
||||
|
||||
// Display name
|
||||
const displayName = document.createElement('div');
|
||||
displayName.className = 'typeahead-displayname';
|
||||
displayName.textContent = actor.displayName || actor.handle;
|
||||
|
||||
// Handle
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'typeahead-handle';
|
||||
handle.textContent = `@${actor.handle}`;
|
||||
|
||||
textContainer.appendChild(displayName);
|
||||
textContainer.appendChild(handle);
|
||||
|
||||
item.appendChild(avatar);
|
||||
item.appendChild(textContainer);
|
||||
|
||||
// Click handler
|
||||
item.addEventListener('click', () => this.selectItem(actor.handle));
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
showRecentAccounts() {
|
||||
const recent = this.getRecentAccounts();
|
||||
if (recent.length === 0) {
|
||||
this.hideDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
this.dropdown.innerHTML = '';
|
||||
this.currentFocus = -1;
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'typeahead-header';
|
||||
header.textContent = 'Recent accounts';
|
||||
this.dropdown.appendChild(header);
|
||||
|
||||
recent.forEach((handle, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'typeahead-item typeahead-recent';
|
||||
item.dataset.index = index;
|
||||
item.dataset.handle = handle;
|
||||
|
||||
const textContainer = document.createElement('div');
|
||||
textContainer.className = 'typeahead-text';
|
||||
|
||||
const handleDiv = document.createElement('div');
|
||||
handleDiv.className = 'typeahead-handle';
|
||||
handleDiv.textContent = handle;
|
||||
|
||||
textContainer.appendChild(handleDiv);
|
||||
item.appendChild(textContainer);
|
||||
|
||||
item.addEventListener('click', () => this.selectItem(handle));
|
||||
|
||||
this.dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
this.dropdown.style.display = 'block';
|
||||
}
|
||||
|
||||
selectItem(handle) {
|
||||
this.input.value = handle;
|
||||
this.hideDropdown();
|
||||
this.saveRecentAccount(handle);
|
||||
// Optionally submit the form automatically
|
||||
// this.input.form.submit();
|
||||
}
|
||||
|
||||
hideDropdown() {
|
||||
this.dropdown.style.display = 'none';
|
||||
this.currentFocus = -1;
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
// If dropdown is hidden, only respond to ArrowDown to show it
|
||||
if (this.dropdown.style.display === 'none') {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const value = this.input.value.trim();
|
||||
if (value.length >= 2) {
|
||||
this.searchActors(value);
|
||||
} else {
|
||||
this.showRecentAccounts();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const items = this.dropdown.querySelectorAll('.typeahead-item');
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.currentFocus++;
|
||||
if (this.currentFocus >= items.length) this.currentFocus = 0;
|
||||
this.updateFocus(items);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.currentFocus--;
|
||||
if (this.currentFocus < 0) this.currentFocus = items.length - 1;
|
||||
this.updateFocus(items);
|
||||
} else if (e.key === 'Enter') {
|
||||
if (this.currentFocus > -1 && items[this.currentFocus]) {
|
||||
e.preventDefault();
|
||||
const handle = items[this.currentFocus].dataset.handle;
|
||||
this.selectItem(handle);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
this.hideDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
updateFocus(items) {
|
||||
items.forEach((item, index) => {
|
||||
if (index === this.currentFocus) {
|
||||
item.classList.add('typeahead-focused');
|
||||
} else {
|
||||
item.classList.remove('typeahead-focused');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getRecentAccounts() {
|
||||
try {
|
||||
const recent = localStorage.getItem('atcr_recent_handles');
|
||||
return recent ? JSON.parse(recent) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
saveRecentAccount(handle) {
|
||||
try {
|
||||
let recent = this.getRecentAccounts();
|
||||
// Remove if already exists
|
||||
recent = recent.filter(h => h !== handle);
|
||||
// Add to front
|
||||
recent.unshift(handle);
|
||||
// Keep only last 5
|
||||
recent = recent.slice(0, 5);
|
||||
localStorage.setItem('atcr_recent_handles', JSON.stringify(recent));
|
||||
} catch (err) {
|
||||
console.error('Failed to save recent account:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize typeahead on login page
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const handleInput = document.getElementById('handle');
|
||||
if (handleInput && handleInput.closest('.login-form')) {
|
||||
new LoginTypeahead(handleInput);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ type RegistryContext struct {
|
||||
Repository string // Image repository name (e.g., "debian")
|
||||
ServiceToken string // Service token for hold authentication (cached by middleware)
|
||||
ATProtoClient *atproto.Client // Authenticated ATProto client for this user
|
||||
AuthMethod string // Auth method used ("oauth" or "app_password")
|
||||
|
||||
// Shared services (same for all requests)
|
||||
Database DatabaseMetrics // Metrics tracking database
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
id="handle"
|
||||
name="handle"
|
||||
placeholder="alice.bsky.social"
|
||||
autocomplete="off"
|
||||
required
|
||||
autofocus />
|
||||
<small>Enter your Bluesky or ATProto handle</small>
|
||||
|
||||
@@ -7,15 +7,22 @@ import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Auth method constants
|
||||
const (
|
||||
AuthMethodOAuth = "oauth"
|
||||
AuthMethodAppPassword = "app_password"
|
||||
)
|
||||
|
||||
// Claims represents the JWT claims for registry authentication
|
||||
// This follows the Docker Registry token specification
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
Access []auth.AccessEntry `json:"access,omitempty"`
|
||||
Access []auth.AccessEntry `json:"access,omitempty"`
|
||||
AuthMethod string `json:"auth_method,omitempty"` // "oauth" or "app_password"
|
||||
}
|
||||
|
||||
// NewClaims creates a new Claims structure with standard fields
|
||||
func NewClaims(subject, issuer, audience string, expiration time.Duration, access []auth.AccessEntry) *Claims {
|
||||
func NewClaims(subject, issuer, audience string, expiration time.Duration, access []auth.AccessEntry, authMethod string) *Claims {
|
||||
now := time.Now()
|
||||
return &Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
@@ -26,6 +33,26 @@ func NewClaims(subject, issuer, audience string, expiration time.Duration, acces
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(expiration)),
|
||||
},
|
||||
Access: access,
|
||||
Access: access,
|
||||
AuthMethod: authMethod, // "oauth" or "app_password"
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractAuthMethod parses a JWT token string and extracts the auth_method claim
|
||||
// Returns the auth method or empty string if not found or token is invalid
|
||||
// This does NOT validate the token - it only parses it to extract the claim
|
||||
func ExtractAuthMethod(tokenString string) string {
|
||||
// Parse token without validation (we only need the claims, validation is done by distribution library)
|
||||
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
||||
token, _, err := parser.ParseUnverified(tokenString, &Claims{})
|
||||
if err != nil {
|
||||
return "" // Invalid token format
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok {
|
||||
return "" // Wrong claims type
|
||||
}
|
||||
|
||||
return claims.AuthMethod
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestNewClaims(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
claims := NewClaims(subject, issuer, audience, expiration, access)
|
||||
claims := NewClaims(subject, issuer, audience, expiration, access, AuthMethodOAuth)
|
||||
|
||||
if claims.Subject != subject {
|
||||
t.Errorf("Expected subject %q, got %q", subject, claims.Subject)
|
||||
@@ -69,7 +69,7 @@ func TestNewClaims(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewClaims_EmptyAccess(t *testing.T) {
|
||||
claims := NewClaims("did:plc:user123", "atcr.io", "registry", 15*time.Minute, nil)
|
||||
claims := NewClaims("did:plc:user123", "atcr.io", "registry", 15*time.Minute, nil, AuthMethodOAuth)
|
||||
|
||||
if claims.Access != nil {
|
||||
t.Error("Expected Access to be nil when not provided")
|
||||
|
||||
@@ -119,6 +119,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var did string
|
||||
var handle string
|
||||
var accessToken string
|
||||
var authMethod string
|
||||
|
||||
// 1. Check if it's a device secret (starts with "atcr_device_")
|
||||
if strings.HasPrefix(password, "atcr_device_") {
|
||||
@@ -131,6 +132,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
did = device.DID
|
||||
handle = device.Handle
|
||||
authMethod = AuthMethodOAuth
|
||||
// Device is linked to OAuth session via DID
|
||||
// OAuth refresher will provide access token when needed via middleware
|
||||
} else {
|
||||
@@ -143,6 +145,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
authMethod = AuthMethodAppPassword
|
||||
|
||||
slog.Debug("App password validated successfully",
|
||||
"did", did,
|
||||
"handle", handle,
|
||||
@@ -178,14 +182,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Issue JWT token
|
||||
tokenString, err := h.issuer.Issue(did, access)
|
||||
tokenString, err := h.issuer.Issue(did, access, authMethod)
|
||||
if err != nil {
|
||||
slog.Error("Failed to issue token", "error", err, "did", did)
|
||||
http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("Issued JWT token", "tokenLength", len(tokenString), "did", did)
|
||||
slog.Debug("Issued JWT token", "tokenLength", len(tokenString), "did", did, "authMethod", authMethod)
|
||||
|
||||
// Return token response
|
||||
now := time.Now()
|
||||
|
||||
@@ -60,8 +60,8 @@ func NewIssuer(privateKeyPath, issuer, service string, expiration time.Duration)
|
||||
}
|
||||
|
||||
// Issue creates and signs a new JWT token
|
||||
func (i *Issuer) Issue(subject string, access []auth.AccessEntry) (string, error) {
|
||||
claims := NewClaims(subject, i.issuer, i.service, i.expiration, access)
|
||||
func (i *Issuer) Issue(subject string, access []auth.AccessEntry, authMethod string) (string, error) {
|
||||
claims := NewClaims(subject, i.issuer, i.service, i.expiration, access, authMethod)
|
||||
|
||||
slog.Debug("Creating JWT token",
|
||||
"issuer", i.issuer,
|
||||
|
||||
@@ -150,7 +150,7 @@ func TestIssuer_Issue(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
token, err := issuer.Issue(subject, access)
|
||||
token, err := issuer.Issue(subject, access, AuthMethodOAuth)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue() error = %v", err)
|
||||
}
|
||||
@@ -174,7 +174,7 @@ func TestIssuer_Issue_EmptyAccess(t *testing.T) {
|
||||
t.Fatalf("NewIssuer() error = %v", err)
|
||||
}
|
||||
|
||||
token, err := issuer.Issue("did:plc:user123", nil)
|
||||
token, err := issuer.Issue("did:plc:user123", nil, AuthMethodOAuth)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue() error = %v", err)
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func TestIssuer_Issue_ValidateToken(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tokenString, err := issuer.Issue(subject, access)
|
||||
tokenString, err := issuer.Issue(subject, access, AuthMethodOAuth)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue() error = %v", err)
|
||||
}
|
||||
@@ -271,7 +271,7 @@ func TestIssuer_Issue_X5CHeader(t *testing.T) {
|
||||
t.Fatalf("NewIssuer() error = %v", err)
|
||||
}
|
||||
|
||||
tokenString, err := issuer.Issue("did:plc:user123", nil)
|
||||
tokenString, err := issuer.Issue("did:plc:user123", nil, "oauth")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue() error = %v", err)
|
||||
}
|
||||
@@ -388,7 +388,7 @@ func TestIssuer_ConcurrentIssue(t *testing.T) {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
subject := "did:plc:user" + string(rune('0'+idx))
|
||||
token, err := issuer.Issue(subject, nil)
|
||||
token, err := issuer.Issue(subject, nil, AuthMethodOAuth)
|
||||
tokens[idx] = token
|
||||
errors[idx] = err
|
||||
}(i)
|
||||
@@ -569,7 +569,7 @@ func TestIssuer_DifferentExpirations(t *testing.T) {
|
||||
t.Fatalf("NewIssuer() error = %v", err)
|
||||
}
|
||||
|
||||
tokenString, err := issuer.Issue("did:plc:user123", nil)
|
||||
tokenString, err := issuer.Issue("did:plc:user123", nil, AuthMethodOAuth)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
)
|
||||
|
||||
@@ -152,3 +153,122 @@ func GetOrFetchServiceToken(
|
||||
slog.Debug("OAuth validation succeeded, service token obtained", "did", did)
|
||||
return serviceToken, nil
|
||||
}
|
||||
|
||||
// GetOrFetchServiceTokenWithAppPassword gets a service token using app-password Bearer authentication.
|
||||
// Used when auth method is app_password instead of OAuth.
|
||||
func GetOrFetchServiceTokenWithAppPassword(
|
||||
ctx context.Context,
|
||||
did, holdDID, pdsEndpoint string,
|
||||
) (string, error) {
|
||||
// Check cache first to avoid unnecessary PDS calls on every request
|
||||
cachedToken, expiresAt := GetServiceToken(did, holdDID)
|
||||
|
||||
// Use cached token if it exists and has > 10s remaining
|
||||
if cachedToken != "" && time.Until(expiresAt) > 10*time.Second {
|
||||
slog.Debug("Using cached service token (app-password)",
|
||||
"did", did,
|
||||
"expiresIn", time.Until(expiresAt).Round(time.Second))
|
||||
return cachedToken, nil
|
||||
}
|
||||
|
||||
// Cache miss or expiring soon - get app-password token and fetch new service token
|
||||
if cachedToken == "" {
|
||||
slog.Debug("Service token cache miss, fetching new token with app-password", "did", did)
|
||||
} else {
|
||||
slog.Debug("Service token expiring soon, proactively renewing with app-password", "did", did)
|
||||
}
|
||||
|
||||
// Get app-password access token from cache
|
||||
accessToken, ok := auth.GetGlobalTokenCache().Get(did)
|
||||
if !ok {
|
||||
InvalidateServiceToken(did, holdDID)
|
||||
slog.Error("No app-password access token found in cache",
|
||||
"component", "token/servicetoken",
|
||||
"did", did,
|
||||
"holdDID", holdDID,
|
||||
"hint", "User must re-authenticate with docker login")
|
||||
return "", fmt.Errorf("no app-password access token available for DID %s", did)
|
||||
}
|
||||
|
||||
// Call com.atproto.server.getServiceAuth on the user's PDS with Bearer token
|
||||
// Request 5-minute expiry (PDS may grant less)
|
||||
// exp must be absolute Unix timestamp, not relative duration
|
||||
expiryTime := time.Now().Unix() + 300 // 5 minutes from now
|
||||
serviceAuthURL := fmt.Sprintf("%s%s?aud=%s&lxm=%s&exp=%d",
|
||||
pdsEndpoint,
|
||||
atproto.ServerGetServiceAuth,
|
||||
url.QueryEscape(holdDID),
|
||||
url.QueryEscape("com.atproto.repo.getRecord"),
|
||||
expiryTime,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create service auth request: %w", err)
|
||||
}
|
||||
|
||||
// Set Bearer token authentication (app-password)
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
// Make request with standard HTTP client
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
InvalidateServiceToken(did, holdDID)
|
||||
slog.Error("App-password service token request failed",
|
||||
"component", "token/servicetoken",
|
||||
"did", did,
|
||||
"holdDID", holdDID,
|
||||
"pdsEndpoint", pdsEndpoint,
|
||||
"error", err)
|
||||
return "", fmt.Errorf("failed to request service token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
// App-password token is invalid or expired - clear from cache
|
||||
auth.GetGlobalTokenCache().Delete(did)
|
||||
InvalidateServiceToken(did, holdDID)
|
||||
slog.Error("App-password token rejected by PDS",
|
||||
"component", "token/servicetoken",
|
||||
"did", did,
|
||||
"hint", "User must re-authenticate with docker login")
|
||||
return "", fmt.Errorf("app-password authentication failed: token expired or invalid")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Service auth failed
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
InvalidateServiceToken(did, holdDID)
|
||||
slog.Error("Service token request returned non-200 status (app-password)",
|
||||
"component", "token/servicetoken",
|
||||
"did", did,
|
||||
"holdDID", holdDID,
|
||||
"pdsEndpoint", pdsEndpoint,
|
||||
"statusCode", resp.StatusCode,
|
||||
"responseBody", string(bodyBytes))
|
||||
return "", fmt.Errorf("service auth failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
// Parse response to get service token
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("failed to decode service auth response: %w", err)
|
||||
}
|
||||
|
||||
if result.Token == "" {
|
||||
return "", fmt.Errorf("empty token in service auth response")
|
||||
}
|
||||
|
||||
serviceToken := result.Token
|
||||
|
||||
// Cache the token (parses JWT to extract actual expiry)
|
||||
if err := SetServiceToken(did, holdDID, serviceToken); err != nil {
|
||||
slog.Warn("Failed to cache service token", "error", err, "did", did, "holdDID", holdDID)
|
||||
// Non-fatal - we have the token, just won't be cached
|
||||
}
|
||||
|
||||
slog.Debug("App-password validation succeeded, service token obtained", "did", did)
|
||||
return serviceToken, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user